mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-13 19:36:16 +01:00
🎞️ refactor: Image Rendering with Preview Caching and Layout Reservation (#12114)
* refactor: Update Image Component to Remove Lazy Loading and Enhance Rendering - Removed the react-lazy-load-image-component dependency from the Image component, simplifying the image loading process. - Updated the Image component to use a standard <img> tag with async decoding for improved performance and user experience. - Adjusted related tests to reflect changes in image rendering behavior and ensure proper functionality without lazy loading. * refactor: Enhance Image Handling and Caching Across Components - Introduced a new previewCache utility for managing local blob preview URLs, improving image loading efficiency. - Updated the Image component and related parts (FileRow, Files, Part, ImageAttachment, LogContent) to utilize cached previews, enhancing rendering performance and user experience. - Added width and height properties to the Image component for better layout management and consistency across different usages. - Improved file handling logic in useFileHandling to cache previews during file uploads, ensuring quick access to image data. - Enhanced overall code clarity and maintainability by streamlining image rendering logic and reducing redundancy. * refactor: Enhance OpenAIImageGen Component with Image Dimensions - Added width and height properties to the OpenAIImageGen component for improved image rendering and layout management. - Updated the Image component usage within OpenAIImageGen to utilize the new dimensions, enhancing visual consistency and performance. - Improved code clarity by destructuring additional properties from the attachment object, streamlining the component's logic. * refactor: Implement Image Size Caching in DialogImage Component - Introduced an imageSizeCache to store and retrieve image sizes, enhancing performance by reducing redundant fetch requests. - Updated the getImageSize function to first check the cache before making network requests, improving efficiency in image handling. - Added decoding attribute to the image element for optimized rendering behavior. * refactor: Enhance UserAvatar Component with Avatar Caching and Error Handling - Introduced avatar caching logic to optimize avatar resolution based on user ID and avatar source, improving performance and reducing redundant image loads. - Implemented error handling for failed image loads, allowing for fallback to a default avatar when necessary. - Updated UserAvatar props to streamline the interface by removing the user object and directly accepting avatar-related properties. - Enhanced overall code clarity and maintainability by refactoring the component structure and logic. * fix: Layout Shift in Message and Placeholder Components for Consistent Height Management - Adjusted the height of the PlaceholderRow and related message components to ensure consistent rendering with a minimum height of 31px. - Updated the MessageParts and ContentRender components to utilize a minimum height for better layout stability. - Enhanced overall code clarity by standardizing the structure of message-related components. * tests: Update FileRow Component to Prefer Cached Previews for Image Rendering - Modified the image URL selection logic in the FileRow component to prioritize cached previews over file paths when uploads are complete, enhancing rendering performance and user experience. - Updated related tests to reflect changes in image URL handling, ensuring accurate assertions for both preview and file path scenarios. - Introduced a fallback mechanism to use file paths when no preview exists, improving robustness in file handling. * fix: Image cache lifecycle and dialog decoding - Add deletePreview/clearPreviewCache to previewCache.ts for blob URL cleanup - Wire deletePreview into useFileDeletion to revoke blobs on file delete - Move dimensionCache.set into useMemo to avoid side effects during render - Extract IMAGE_MAX_W_PX constant (512) to document coupling with max-w-lg - Export _resetImageCaches for test isolation - Change DialogImage decoding from "sync" to "async" to avoid blocking main thread * fix: Avatar cache invalidation and cleanup - Include avatarSrc in cache invalidation to prevent stale avatars - Remove unused username parameter from resolveAvatar - Skip caching when userId is empty to prevent cache key collisions * test: Fix test isolation and type safety - Reset module-level dimensionCache/paintedUrls in beforeEach via _resetImageCaches - Replace any[] with typed mock signature in cn mock for both test files * chore: Code quality improvements from review - Use barrel imports for previewCache in Files.tsx and Part.tsx - Single Map.get with truthy check instead of has+get in useEventHandlers - Add JSDoc comments explaining EmptyText margin removal and PlaceholderRow height - Fix FileRow toast showing "Deleting file" when file isn't actually deleted (progress < 1) * fix: Address remaining review findings (R1-R3) - Add deletePreview calls to deleteFiles batch path to prevent blob URL leaks - Change useFileDeletion import from deep path to barrel (~/utils) - Change useMemo to useEffect for dimensionCache.set (side effect, not derived value) * fix: Address audit comments 2, 5, and 7 - Fix files preservation to distinguish null (missing) from [] (empty) in finalHandler - Add auto-revoke on overwrite in cachePreview to prevent leaked blobs - Add removePreviewEntry for key transfer without revoke - Clean up stale temp_file_id cache entry after promotion to permanent file_id
This commit is contained in:
parent
6d0938be64
commit
b93d60c416
26 changed files with 390 additions and 247 deletions
|
|
@ -51,7 +51,9 @@ jest.mock('~/data-provider', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks/useLocalize', () => {
|
||||
const fn = jest.fn((key: string) => key);
|
||||
const fn = jest.fn((key: string) => key) as jest.Mock & {
|
||||
TranslationKeys: Record<string, never>;
|
||||
};
|
||||
fn.TranslationKeys = {};
|
||||
return { __esModule: true, default: fn, TranslationKeys: {} };
|
||||
});
|
||||
|
|
@ -87,6 +89,8 @@ jest.mock('../useUpdateFiles', () => ({
|
|||
jest.mock('~/utils', () => ({
|
||||
logger: { log: jest.fn() },
|
||||
validateFiles: jest.fn(() => true),
|
||||
cachePreview: jest.fn(),
|
||||
getCachedPreview: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
const mockValidateFiles = jest.requireMock('~/utils').validateFiles;
|
||||
|
|
@ -263,7 +267,7 @@ describe('useFileHandling', () => {
|
|||
|
||||
it('falls back to "default" when no conversation endpoint and no override', async () => {
|
||||
mockConversation = {
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: null,
|
||||
endpointType: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type * as t from 'librechat-data-provider';
|
|||
import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
||||
import type { ExtendedFile, GenericSetter } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
import { deletePreview } from '~/utils';
|
||||
|
||||
type FileMapSetter = GenericSetter<Map<string, ExtendedFile>>;
|
||||
|
||||
|
|
@ -88,6 +89,11 @@ const useFileDeletion = ({
|
|||
});
|
||||
}
|
||||
|
||||
deletePreview(file_id);
|
||||
if (temp_file_id) {
|
||||
deletePreview(temp_file_id);
|
||||
}
|
||||
|
||||
if (attached) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -125,6 +131,11 @@ const useFileDeletion = ({
|
|||
temp_file_id,
|
||||
embedded: embedded ?? false,
|
||||
});
|
||||
|
||||
deletePreview(file_id);
|
||||
if (temp_file_id) {
|
||||
deletePreview(temp_file_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (setFiles) {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ import debounce from 'lodash/debounce';
|
|||
import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { logger, validateFiles, cachePreview, getCachedPreview, removePreviewEntry } from '~/utils';
|
||||
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import { processFileForUpload } from '~/utils/heicConverter';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { logger, validateFiles } from '~/utils';
|
||||
import useClientResize from './useClientResize';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
|
|
@ -130,6 +130,11 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const cachedBlob = getCachedPreview(data.temp_file_id);
|
||||
if (cachedBlob && data.file_id !== data.temp_file_id) {
|
||||
cachePreview(data.file_id, cachedBlob);
|
||||
removePreviewEntry(data.temp_file_id);
|
||||
}
|
||||
updateFileById(
|
||||
data.temp_file_id,
|
||||
{
|
||||
|
|
@ -260,7 +265,6 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
replaceFile(extendedFile);
|
||||
|
||||
await startUpload(extendedFile);
|
||||
URL.revokeObjectURL(preview);
|
||||
};
|
||||
img.src = preview;
|
||||
};
|
||||
|
|
@ -301,6 +305,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
try {
|
||||
// Create initial preview with original file
|
||||
const initialPreview = URL.createObjectURL(originalFile);
|
||||
cachePreview(file_id, initialPreview);
|
||||
|
||||
// Create initial ExtendedFile to show immediately
|
||||
const initialExtendedFile: ExtendedFile = {
|
||||
|
|
@ -378,6 +383,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
if (finalProcessedFile !== originalFile) {
|
||||
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||
const newPreview = URL.createObjectURL(finalProcessedFile);
|
||||
cachePreview(file_id, newPreview);
|
||||
|
||||
const updatedExtendedFile: ExtendedFile = {
|
||||
...initialExtendedFile,
|
||||
|
|
|
|||
|
|
@ -526,6 +526,23 @@ export default function useEventHandlers({
|
|||
} else if (requestMessage != null && responseMessage != null) {
|
||||
finalMessages = [...messages, requestMessage, responseMessage];
|
||||
}
|
||||
|
||||
/* Preserve files from current messages when server response lacks them */
|
||||
if (finalMessages.length > 0) {
|
||||
const currentMsgMap = new Map(
|
||||
currentMessages
|
||||
.filter((m) => m.files && m.files.length > 0)
|
||||
.map((m) => [m.messageId, m.files]),
|
||||
);
|
||||
for (let i = 0; i < finalMessages.length; i++) {
|
||||
const msg = finalMessages[i];
|
||||
const preservedFiles = currentMsgMap.get(msg.messageId);
|
||||
if (msg.files == null && preservedFiles) {
|
||||
finalMessages[i] = { ...msg, files: preservedFiles };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalMessages.length > 0) {
|
||||
setFinalMessages(conversation.conversationId, finalMessages);
|
||||
} else if (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue