mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-07 08:40:19 +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
|
|
@ -94,7 +94,6 @@
|
||||||
"react-gtm-module": "^2.0.11",
|
"react-gtm-module": "^2.0.11",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-lazy-load-image-component": "^1.6.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
|
@ -147,9 +146,9 @@
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"monaco-editor": "^0.55.0",
|
|
||||||
"jest-file-loader": "^1.0.3",
|
"jest-file-loader": "^1.0.3",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
|
"monaco-editor": "^0.55.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-preset-env": "^11.2.0",
|
"postcss-preset-env": "^11.2.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { useToastContext } from '@librechat/client';
|
||||||
import { EToolResources } from 'librechat-data-provider';
|
import { EToolResources } from 'librechat-data-provider';
|
||||||
import type { ExtendedFile } from '~/common';
|
import type { ExtendedFile } from '~/common';
|
||||||
import { useDeleteFilesMutation } from '~/data-provider';
|
import { useDeleteFilesMutation } from '~/data-provider';
|
||||||
|
import { logger, getCachedPreview } from '~/utils';
|
||||||
import { useFileDeletion } from '~/hooks/Files';
|
import { useFileDeletion } from '~/hooks/Files';
|
||||||
import FileContainer from './FileContainer';
|
import FileContainer from './FileContainer';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { logger } from '~/utils';
|
|
||||||
import Image from './Image';
|
import Image from './Image';
|
||||||
|
|
||||||
export default function FileRow({
|
export default function FileRow({
|
||||||
|
|
@ -112,13 +112,15 @@ export default function FileRow({
|
||||||
)
|
)
|
||||||
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_deleting_file'),
|
|
||||||
status: 'info',
|
|
||||||
});
|
|
||||||
if (abortUpload && file.progress < 1) {
|
if (abortUpload && file.progress < 1) {
|
||||||
abortUpload();
|
abortUpload();
|
||||||
}
|
}
|
||||||
|
if (file.progress >= 1) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_deleting_file'),
|
||||||
|
status: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
deleteFile({ file, setFiles });
|
deleteFile({ file, setFiles });
|
||||||
};
|
};
|
||||||
const isImage = file.type?.startsWith('image') ?? false;
|
const isImage = file.type?.startsWith('image') ?? false;
|
||||||
|
|
@ -134,7 +136,7 @@ export default function FileRow({
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<Image
|
<Image
|
||||||
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
|
url={getCachedPreview(file.file_id) ?? file.preview ?? file.filepath}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
progress={file.progress}
|
progress={file.progress}
|
||||||
source={file.source}
|
source={file.source}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ jest.mock('~/utils', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
log: jest.fn(),
|
log: jest.fn(),
|
||||||
},
|
},
|
||||||
|
getCachedPreview: jest.fn(() => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../Image', () => {
|
jest.mock('../Image', () => {
|
||||||
|
|
@ -95,7 +96,7 @@ describe('FileRow', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Image URL Selection Logic', () => {
|
describe('Image URL Selection Logic', () => {
|
||||||
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
|
it('should prefer cached preview over filepath when upload is complete', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'uploaded-file',
|
file_id: 'uploaded-file',
|
||||||
preview: 'blob:http://localhost:3080/temp-preview',
|
preview: 'blob:http://localhost:3080/temp-preview',
|
||||||
|
|
@ -109,8 +110,7 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
|
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
|
||||||
expect(imageUrl).not.toContain('blob:');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use preview when progress is less than 1 (uploading)', () => {
|
it('should use preview when progress is less than 1 (uploading)', () => {
|
||||||
|
|
@ -147,7 +147,7 @@ describe('FileRow', () => {
|
||||||
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
|
it('should prefer preview over filepath when both exist and progress is 1', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'complete-file',
|
file_id: 'complete-file',
|
||||||
preview: 'blob:http://localhost:3080/old-blob',
|
preview: 'blob:http://localhost:3080/old-blob',
|
||||||
|
|
@ -161,7 +161,7 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
|
expect(imageUrl).toBe('blob:http://localhost:3080/old-blob');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -284,7 +284,7 @@ describe('FileRow', () => {
|
||||||
|
|
||||||
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
||||||
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
||||||
expect(urls).toContain('/images/user123/file-2__image.png');
|
expect(urls).toContain('blob:http://localhost:3080/preview-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate files with the same file_id', () => {
|
it('should deduplicate files with the same file_id', () => {
|
||||||
|
|
@ -321,10 +321,10 @@ describe('FileRow', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Regression: Blob URL Bug Fix', () => {
|
describe('Preview Cache Integration', () => {
|
||||||
it('should NOT use revoked blob URL after upload completes', () => {
|
it('should prefer preview blob URL over filepath for zero-flicker rendering', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'regression-test',
|
file_id: 'cache-test',
|
||||||
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
||||||
filepath:
|
filepath:
|
||||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
|
@ -337,8 +337,24 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b');
|
||||||
|
});
|
||||||
|
|
||||||
expect(imageUrl).not.toContain('blob:');
|
it('should fall back to filepath when no preview exists', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'no-preview',
|
||||||
|
preview: undefined,
|
||||||
|
filepath:
|
||||||
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe(
|
expect(imageUrl).toBe(
|
||||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client';
|
||||||
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
const imageSizeCache = new Map<string, string>();
|
||||||
|
|
||||||
const getQualityStyles = (quality: string): string => {
|
const getQualityStyles = (quality: string): string => {
|
||||||
if (quality === 'high') {
|
if (quality === 'high') {
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-100 text-green-800';
|
||||||
|
|
@ -50,18 +52,26 @@ export default function DialogImage({
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const getImageSize = useCallback(async (url: string) => {
|
const getImageSize = useCallback(async (url: string) => {
|
||||||
|
const cached = imageSizeCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
const contentLength = response.headers.get('Content-Length');
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
|
||||||
if (contentLength) {
|
if (contentLength) {
|
||||||
const bytes = parseInt(contentLength, 10);
|
const bytes = parseInt(contentLength, 10);
|
||||||
return formatFileSize(bytes);
|
const result = formatFileSize(bytes);
|
||||||
|
imageSizeCache.set(url, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullResponse = await fetch(url);
|
const fullResponse = await fetch(url);
|
||||||
const blob = await fullResponse.blob();
|
const blob = await fullResponse.blob();
|
||||||
return formatFileSize(blob.size);
|
const result = formatFileSize(blob.size);
|
||||||
|
imageSizeCache.set(url, result);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting image size:', error);
|
console.error('Error getting image size:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -355,6 +365,7 @@ export default function DialogImage({
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
src={src}
|
src={src}
|
||||||
alt="Image"
|
alt="Image"
|
||||||
|
decoding="async"
|
||||||
className="block max-h-[85vh] object-contain"
|
className="block max-h-[85vh] object-contain"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: getImageMaxWidth(),
|
maxWidth: getImageMaxWidth(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, memo } from 'react';
|
import { useMemo, memo } from 'react';
|
||||||
import type { TFile, TMessage } from 'librechat-data-provider';
|
import type { TFile, TMessage } from 'librechat-data-provider';
|
||||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||||
|
import { getCachedPreview } from '~/utils';
|
||||||
import Image from './Image';
|
import Image from './Image';
|
||||||
|
|
||||||
const Files = ({ message }: { message?: TMessage }) => {
|
const Files = ({ message }: { message?: TMessage }) => {
|
||||||
|
|
@ -17,13 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => {
|
||||||
{otherFiles.length > 0 &&
|
{otherFiles.length > 0 &&
|
||||||
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
|
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
|
||||||
{imageFiles.length > 0 &&
|
{imageFiles.length > 0 &&
|
||||||
imageFiles.map((file) => (
|
imageFiles.map((file) => {
|
||||||
<Image
|
const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
|
||||||
key={file.file_id}
|
return (
|
||||||
imagePath={file.preview ?? file.filepath ?? ''}
|
<Image
|
||||||
altText={file.filename ?? 'Uploaded Image'}
|
key={file.file_id}
|
||||||
/>
|
width={file.width}
|
||||||
))}
|
height={file.height}
|
||||||
|
altText={file.filename ?? 'Uploaded Image'}
|
||||||
|
imagePath={cached ?? file.preview ?? file.filepath ?? ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,36 @@
|
||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Skeleton } from '@librechat/client';
|
import { Skeleton } from '@librechat/client';
|
||||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
|
||||||
import { apiBaseUrl } from 'librechat-data-provider';
|
import { apiBaseUrl } from 'librechat-data-provider';
|
||||||
import { cn } from '~/utils';
|
|
||||||
import DialogImage from './DialogImage';
|
import DialogImage from './DialogImage';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
/** Max display height for chat images (Tailwind JIT class) */
|
/** Max display height for chat images (Tailwind JIT class) */
|
||||||
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
export const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||||
|
/** Matches the `max-w-lg` Tailwind class on the wrapper button (32rem = 512px at 16px base) */
|
||||||
|
const IMAGE_MAX_W_PX = 512;
|
||||||
|
|
||||||
|
/** Caches image dimensions by src so remounts can reserve space */
|
||||||
|
const dimensionCache = new Map<string, { width: number; height: number }>();
|
||||||
|
/** Tracks URLs that have been fully painted — skip skeleton on remount */
|
||||||
|
const paintedUrls = new Set<string>();
|
||||||
|
|
||||||
|
/** Test-only: resets module-level caches */
|
||||||
|
export function _resetImageCaches(): void {
|
||||||
|
dimensionCache.clear();
|
||||||
|
paintedUrls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeHeightStyle(w: number, h: number): React.CSSProperties {
|
||||||
|
return { height: `min(45vh, ${(h / w) * 100}vw, ${(h / w) * IMAGE_MAX_W_PX}px)` };
|
||||||
|
}
|
||||||
|
|
||||||
const Image = ({
|
const Image = ({
|
||||||
imagePath,
|
imagePath,
|
||||||
altText,
|
altText,
|
||||||
className,
|
className,
|
||||||
args,
|
args,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
}: {
|
}: {
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
altText: string;
|
altText: string;
|
||||||
|
|
@ -24,18 +42,15 @@ const Image = ({
|
||||||
style?: string;
|
style?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleImageLoad = () => setIsLoaded(true);
|
|
||||||
|
|
||||||
// Fix image path to include base path for subdirectory deployments
|
|
||||||
const absoluteImageUrl = useMemo(() => {
|
const absoluteImageUrl = useMemo(() => {
|
||||||
if (!imagePath) return imagePath;
|
if (!imagePath) return imagePath;
|
||||||
|
|
||||||
// If it's already an absolute URL or doesn't start with /images/, return as is
|
|
||||||
if (
|
if (
|
||||||
imagePath.startsWith('http') ||
|
imagePath.startsWith('http') ||
|
||||||
imagePath.startsWith('data:') ||
|
imagePath.startsWith('data:') ||
|
||||||
|
|
@ -44,7 +59,6 @@ const Image = ({
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the base URL and prepend it to the image path
|
|
||||||
const baseURL = apiBaseUrl();
|
const baseURL = apiBaseUrl();
|
||||||
return `${baseURL}${imagePath}`;
|
return `${baseURL}${imagePath}`;
|
||||||
}, [imagePath]);
|
}, [imagePath]);
|
||||||
|
|
@ -78,6 +92,17 @@ const Image = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (width && height && absoluteImageUrl) {
|
||||||
|
dimensionCache.set(absoluteImageUrl, { width, height });
|
||||||
|
}
|
||||||
|
}, [absoluteImageUrl, width, height]);
|
||||||
|
|
||||||
|
const dims = width && height ? { width, height } : dimensionCache.get(absoluteImageUrl);
|
||||||
|
const hasDimensions = !!(dims?.width && dims?.height);
|
||||||
|
const heightStyle = hasDimensions ? computeHeightStyle(dims.width, dims.height) : undefined;
|
||||||
|
const showSkeleton = hasDimensions && !paintedUrls.has(absoluteImageUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -87,40 +112,33 @@ const Image = ({
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
'relative mt-1 w-full max-w-lg cursor-pointer overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={heightStyle}
|
||||||
>
|
>
|
||||||
<LazyLoadImage
|
{showSkeleton && <Skeleton className="absolute inset-0" aria-hidden="true" />}
|
||||||
|
<img
|
||||||
alt={altText}
|
alt={altText}
|
||||||
onLoad={handleImageLoad}
|
|
||||||
visibleByDefault={true}
|
|
||||||
className={cn(
|
|
||||||
'block h-auto w-auto max-w-full text-transparent transition-opacity duration-100',
|
|
||||||
IMAGE_MAX_H,
|
|
||||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
src={absoluteImageUrl}
|
src={absoluteImageUrl}
|
||||||
placeholder={
|
onLoad={() => paintedUrls.add(absoluteImageUrl)}
|
||||||
<Skeleton
|
className={cn(
|
||||||
className={cn(IMAGE_MAX_H, 'h-48 w-full max-w-lg')}
|
'relative block text-transparent',
|
||||||
aria-label="Loading image"
|
hasDimensions
|
||||||
aria-busy="true"
|
? 'size-full object-contain'
|
||||||
/>
|
: cn('h-auto w-auto max-w-full', IMAGE_MAX_H),
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isLoaded && (
|
<DialogImage
|
||||||
<DialogImage
|
isOpen={isOpen}
|
||||||
isOpen={isOpen}
|
onOpenChange={setIsOpen}
|
||||||
onOpenChange={setIsOpen}
|
src={absoluteImageUrl}
|
||||||
src={absoluteImageUrl}
|
downloadImage={downloadImage}
|
||||||
downloadImage={downloadImage}
|
args={args}
|
||||||
args={args}
|
triggerRef={triggerRef}
|
||||||
triggerRef={triggerRef}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
|
||||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||||
import { ErrorMessage } from './MessageContent';
|
import { ErrorMessage } from './MessageContent';
|
||||||
import RetrievalCall from './RetrievalCall';
|
import RetrievalCall from './RetrievalCall';
|
||||||
|
import { getCachedPreview } from '~/utils';
|
||||||
import AgentHandoff from './AgentHandoff';
|
import AgentHandoff from './AgentHandoff';
|
||||||
import CodeAnalyze from './CodeAnalyze';
|
import CodeAnalyze from './CodeAnalyze';
|
||||||
import Container from './Container';
|
import Container from './Container';
|
||||||
|
|
@ -222,8 +223,14 @@ const Part = memo(function Part({
|
||||||
}
|
}
|
||||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||||
|
const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined;
|
||||||
return (
|
return (
|
||||||
<Image imagePath={imageFile.filepath} altText={imageFile.filename ?? 'Uploaded Image'} />
|
<Image
|
||||||
|
imagePath={cached ?? imageFile.filepath}
|
||||||
|
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||||
|
width={imageFile.width}
|
||||||
|
height={imageFile.height}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment>
|
||||||
|
|
||||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
|
|
@ -76,6 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||||
<Image
|
<Image
|
||||||
altText={attachment.filename || 'attachment image'}
|
altText={attachment.filename || 'attachment image'}
|
||||||
imagePath={filepath ?? ''}
|
imagePath={filepath ?? ''}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
|
||||||
const EmptyTextPart = memo(() => {
|
const EmptyTextPart = memo(() => {
|
||||||
return (
|
return (
|
||||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||||
<div className="absolute">
|
<div className="absolute">
|
||||||
<p className="submitting relative">
|
<p className="submitting relative">
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
||||||
)}
|
)}
|
||||||
{imageAttachments?.map((attachment) => (
|
{imageAttachments?.map((attachment) => (
|
||||||
<Image
|
<Image
|
||||||
|
width={attachment.width}
|
||||||
|
height={attachment.height}
|
||||||
key={attachment.filepath}
|
key={attachment.filepath}
|
||||||
altText={attachment.filename}
|
altText={attachment.filename}
|
||||||
imagePath={attachment.filepath}
|
imagePath={attachment.filepath}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,12 @@ export default function OpenAIImageGen({
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = attachments?.[0];
|
const attachment = attachments?.[0];
|
||||||
const { filepath = null, filename = '' } = (attachment as TFile & TAttachmentMetadata) || {};
|
const {
|
||||||
|
filepath = null,
|
||||||
|
filename = '',
|
||||||
|
width: imgWidth,
|
||||||
|
height: imgHeight,
|
||||||
|
} = (attachment as TFile & TAttachmentMetadata) || {};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
|
|
@ -120,9 +125,11 @@ export default function OpenAIImageGen({
|
||||||
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
|
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
|
||||||
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
|
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
|
||||||
<Image
|
<Image
|
||||||
|
width={imgWidth}
|
||||||
|
args={parsedArgs}
|
||||||
|
height={imgHeight}
|
||||||
altText={filename}
|
altText={filename}
|
||||||
imagePath={filepath ?? ''}
|
imagePath={filepath ?? ''}
|
||||||
args={parsedArgs}
|
|
||||||
className={progress < 1 ? 'invisible absolute' : ''}
|
className={progress < 1 ? 'invisible absolute' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import Image from '../Image';
|
import Image, { _resetImageCaches } from '../Image';
|
||||||
|
|
||||||
jest.mock('~/utils', () => ({
|
jest.mock('~/utils', () => ({
|
||||||
cn: (...classes: unknown[]) =>
|
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||||
classes
|
classes
|
||||||
.flat(Infinity)
|
.flat(Infinity)
|
||||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -14,38 +14,6 @@ jest.mock('librechat-data-provider', () => ({
|
||||||
apiBaseUrl: () => '',
|
apiBaseUrl: () => '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('react-lazy-load-image-component', () => ({
|
|
||||||
LazyLoadImage: ({
|
|
||||||
alt,
|
|
||||||
src,
|
|
||||||
className,
|
|
||||||
onLoad,
|
|
||||||
placeholder,
|
|
||||||
visibleByDefault: _visibleByDefault,
|
|
||||||
...rest
|
|
||||||
}: {
|
|
||||||
alt: string;
|
|
||||||
src: string;
|
|
||||||
className: string;
|
|
||||||
onLoad: () => void;
|
|
||||||
placeholder: React.ReactNode;
|
|
||||||
visibleByDefault?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}) => (
|
|
||||||
<div data-testid="lazy-image-wrapper">
|
|
||||||
<img
|
|
||||||
alt={alt}
|
|
||||||
src={src}
|
|
||||||
className={className}
|
|
||||||
onLoad={onLoad}
|
|
||||||
data-testid="lazy-image"
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
<div data-testid="placeholder">{placeholder}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@librechat/client', () => ({
|
jest.mock('@librechat/client', () => ({
|
||||||
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div data-testid="skeleton" className={className} {...props} />
|
<div data-testid="skeleton" className={className} {...props} />
|
||||||
|
|
@ -65,45 +33,84 @@ describe('Image', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
_resetImageCaches();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe('rendering without dimensions', () => {
|
||||||
it('renders with max-h-[45vh] height constraint', () => {
|
it('renders with max-h-[45vh] height constraint', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img.className).toContain('max-h-[45vh]');
|
expect(img.className).toContain('max-h-[45vh]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with max-w-full to prevent landscape clipping', () => {
|
it('renders with max-w-full to prevent landscape clipping', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img.className).toContain('max-w-full');
|
expect(img.className).toContain('max-w-full');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with w-auto and h-auto for natural aspect ratio', () => {
|
it('renders with w-auto and h-auto for natural aspect ratio', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img.className).toContain('w-auto');
|
expect(img.className).toContain('w-auto');
|
||||||
expect(img.className).toContain('h-auto');
|
expect(img.className).toContain('h-auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts with opacity-0 before image loads', () => {
|
it('does not show skeleton without dimensions', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||||
expect(img.className).toContain('opacity-0');
|
|
||||||
expect(img.className).not.toContain('opacity-100');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('transitions to opacity-100 after image loads', () => {
|
it('does not apply heightStyle without dimensions', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.style.height).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering with dimensions', () => {
|
||||||
|
it('shows skeleton behind image', () => {
|
||||||
|
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||||
|
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies computed heightStyle to button', () => {
|
||||||
|
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.style.height).toBeTruthy();
|
||||||
|
expect(button.style.height).toContain('min(45vh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses size-full object-contain on image when dimensions provided', () => {
|
||||||
|
render(<Image {...defaultProps} width={768} height={916} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img.className).toContain('size-full');
|
||||||
|
expect(img.className).toContain('object-contain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skeleton is absolute inset-0', () => {
|
||||||
|
render(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
const skeleton = screen.getByTestId('skeleton');
|
||||||
|
expect(skeleton.className).toContain('absolute');
|
||||||
|
expect(skeleton.className).toContain('inset-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks URL as painted on load and skips skeleton on rerender', () => {
|
||||||
|
const { rerender } = render(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.load(img);
|
fireEvent.load(img);
|
||||||
|
|
||||||
expect(img.className).toContain('opacity-100');
|
// Rerender same component — skeleton should not show (URL painted)
|
||||||
|
rerender(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('common behavior', () => {
|
||||||
it('applies custom className to the button wrapper', () => {
|
it('applies custom className to the button wrapper', () => {
|
||||||
render(<Image {...defaultProps} className="mb-4" />);
|
render(<Image {...defaultProps} className="mb-4" />);
|
||||||
const button = screen.getByRole('button');
|
const button = screen.getByRole('button');
|
||||||
|
|
@ -112,57 +119,9 @@ describe('Image', () => {
|
||||||
|
|
||||||
it('sets correct alt text', () => {
|
it('sets correct alt text', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('alt', 'Test image');
|
expect(img).toHaveAttribute('alt', 'Test image');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('skeleton placeholder', () => {
|
|
||||||
it('renders skeleton with non-zero dimensions', () => {
|
|
||||||
render(<Image {...defaultProps} />);
|
|
||||||
const skeleton = screen.getByTestId('skeleton');
|
|
||||||
expect(skeleton.className).toContain('h-48');
|
|
||||||
expect(skeleton.className).toContain('w-full');
|
|
||||||
expect(skeleton.className).toContain('max-w-lg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders skeleton with max-h constraint', () => {
|
|
||||||
render(<Image {...defaultProps} />);
|
|
||||||
const skeleton = screen.getByTestId('skeleton');
|
|
||||||
expect(skeleton.className).toContain('max-h-[45vh]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has accessible loading attributes', () => {
|
|
||||||
render(<Image {...defaultProps} />);
|
|
||||||
const skeleton = screen.getByTestId('skeleton');
|
|
||||||
expect(skeleton).toHaveAttribute('aria-label', 'Loading image');
|
|
||||||
expect(skeleton).toHaveAttribute('aria-busy', 'true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dialog interaction', () => {
|
|
||||||
it('opens dialog on button click after image loads', () => {
|
|
||||||
render(<Image {...defaultProps} />);
|
|
||||||
|
|
||||||
const img = screen.getByTestId('lazy-image');
|
|
||||||
fireEvent.load(img);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
const button = screen.getByRole('button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render dialog before image loads', () => {
|
|
||||||
render(<Image {...defaultProps} />);
|
|
||||||
|
|
||||||
const button = screen.getByRole('button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has correct accessibility attributes on button', () => {
|
it('has correct accessibility attributes on button', () => {
|
||||||
render(<Image {...defaultProps} />);
|
render(<Image {...defaultProps} />);
|
||||||
|
|
@ -172,28 +131,48 @@ describe('Image', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('dialog interaction', () => {
|
||||||
|
it('opens dialog on button click', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dialog is always mounted (not gated by load state)', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
// DialogImage mock returns null when isOpen=false, but the component is in the tree
|
||||||
|
// Clicking should immediately show it
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('image URL resolution', () => {
|
describe('image URL resolution', () => {
|
||||||
it('passes /images/ paths through with base URL', () => {
|
it('passes /images/ paths through with base URL', () => {
|
||||||
render(<Image {...defaultProps} imagePath="/images/test.png" />);
|
render(<Image {...defaultProps} imagePath="/images/test.png" />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('src', '/images/test.png');
|
expect(img).toHaveAttribute('src', '/images/test.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes absolute http URLs through unchanged', () => {
|
it('passes absolute http URLs through unchanged', () => {
|
||||||
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
|
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes data URIs through unchanged', () => {
|
it('passes data URIs through unchanged', () => {
|
||||||
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
|
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
|
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes non-/images/ paths through unchanged', () => {
|
it('passes non-/images/ paths through unchanged', () => {
|
||||||
render(<Image {...defaultProps} imagePath="/other/path.png" />);
|
render(<Image {...defaultProps} imagePath="/other/path.png" />);
|
||||||
const img = screen.getByTestId('lazy-image');
|
const img = screen.getByRole('img');
|
||||||
expect(img).toHaveAttribute('src', '/other/path.png');
|
expect(img).toHaveAttribute('src', '/other/path.png');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { render, screen } from '@testing-library/react';
|
||||||
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
|
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
|
||||||
|
|
||||||
jest.mock('~/utils', () => ({
|
jest.mock('~/utils', () => ({
|
||||||
cn: (...classes: unknown[]) =>
|
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||||
classes
|
classes
|
||||||
.flat(Infinity)
|
.flat(Infinity)
|
||||||
.filter((c) => typeof c === 'string' && c.length > 0)
|
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||||
.join(' '),
|
.join(' '),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) {
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
<ContentParts
|
<ContentParts
|
||||||
edit={edit}
|
edit={edit}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|
@ -147,7 +147,7 @@ export default function Message(props: TMessageProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isLast && isSubmitting ? (
|
{isLast && isSubmitting ? (
|
||||||
<div className="mt-1 h-[27px] bg-transparent" />
|
<div className="mt-1 h-[31px] bg-transparent" />
|
||||||
) : (
|
) : (
|
||||||
<SubRow classes="text-xs">
|
<SubRow classes="text-xs">
|
||||||
<SiblingSwitch
|
<SiblingSwitch
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ const MessageRender = memo(function MessageRender({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
<MessageContext.Provider value={messageContextValue}>
|
<MessageContext.Provider value={messageContextValue}>
|
||||||
<MessageContent
|
<MessageContent
|
||||||
ask={ask}
|
ask={ask}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
/** Height matches the SubRow action buttons row (31px) — keep in sync with HoverButtons */
|
||||||
const PlaceholderRow = memo(function PlaceholderRow() {
|
const PlaceholderRow = memo(function PlaceholderRow() {
|
||||||
return <div className="mt-1 h-[27px] bg-transparent" />;
|
return <div className="mt-1 h-[31px] bg-transparent" />;
|
||||||
});
|
});
|
||||||
PlaceholderRow.displayName = 'PlaceholderRow';
|
PlaceholderRow.displayName = 'PlaceholderRow';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,102 @@
|
||||||
import React, { memo, useState } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { UserIcon, useAvatar } from '@librechat/client';
|
import { UserIcon, useAvatar } from '@librechat/client';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
|
||||||
import type { IconProps } from '~/common';
|
import type { IconProps } from '~/common';
|
||||||
import MessageEndpointIcon from './MessageEndpointIcon';
|
import MessageEndpointIcon from './MessageEndpointIcon';
|
||||||
import { useAuthContext } from '~/hooks/AuthContext';
|
import { useAuthContext } from '~/hooks/AuthContext';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches the resolved avatar decision per user ID.
|
||||||
|
* Invalidated when `user.avatar` changes (e.g., settings upload).
|
||||||
|
* Tracks failed image URLs so they fall back to SVG permanently for the session.
|
||||||
|
*/
|
||||||
|
const avatarCache = new Map<
|
||||||
|
string,
|
||||||
|
{ avatar: string; avatarSrc: string; resolved: ResolvedAvatar }
|
||||||
|
>();
|
||||||
|
const failedUrls = new Set<string>();
|
||||||
|
|
||||||
|
function resolveAvatar(userId: string, userAvatar: string, avatarSrc: string): ResolvedAvatar {
|
||||||
|
if (!userId) {
|
||||||
|
const imgSrc = userAvatar || avatarSrc;
|
||||||
|
return imgSrc && !failedUrls.has(imgSrc)
|
||||||
|
? { type: 'image', src: imgSrc }
|
||||||
|
: { type: 'fallback' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = avatarCache.get(userId);
|
||||||
|
if (cached && cached.avatar === userAvatar && cached.avatarSrc === avatarSrc) {
|
||||||
|
return cached.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgSrc = userAvatar || avatarSrc;
|
||||||
|
const resolved: ResolvedAvatar =
|
||||||
|
imgSrc && !failedUrls.has(imgSrc) ? { type: 'image', src: imgSrc } : { type: 'fallback' };
|
||||||
|
|
||||||
|
avatarCache.set(userId, { avatar: userAvatar, avatarSrc, resolved });
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAvatarFailed(userId: string, src: string): ResolvedAvatar {
|
||||||
|
failedUrls.add(src);
|
||||||
|
const fallback: ResolvedAvatar = { type: 'fallback' };
|
||||||
|
const cached = avatarCache.get(userId);
|
||||||
|
if (cached) {
|
||||||
|
avatarCache.set(userId, { ...cached, resolved: fallback });
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
size: number;
|
size: number;
|
||||||
user?: TUser;
|
avatar: string;
|
||||||
avatarSrc: string;
|
avatarSrc: string;
|
||||||
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
|
const UserAvatar = memo(
|
||||||
const [imageError, setImageError] = useState(false);
|
({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => {
|
||||||
|
const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc));
|
||||||
|
|
||||||
const handleImageError = () => {
|
React.useEffect(() => {
|
||||||
setImageError(true);
|
setResolved(resolveAvatar(userId, avatar, avatarSrc));
|
||||||
};
|
}, [userId, avatar, avatarSrc]);
|
||||||
|
|
||||||
const renderDefaultAvatar = () => (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
title={username}
|
||||||
backgroundColor: 'rgb(121, 137, 255)',
|
style={{ width: size, height: size }}
|
||||||
width: '20px',
|
className={cn('relative flex items-center justify-center', className ?? '')}
|
||||||
height: '20px',
|
>
|
||||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
{resolved.type === 'image' ? (
|
||||||
}}
|
<img
|
||||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
className="rounded-full"
|
||||||
>
|
src={resolved.src}
|
||||||
<UserIcon />
|
alt="avatar"
|
||||||
</div>
|
onError={() => setResolved(markAvatarFailed(userId, resolved.src))}
|
||||||
);
|
/>
|
||||||
|
) : (
|
||||||
return (
|
<div
|
||||||
<div
|
style={{
|
||||||
title={username}
|
backgroundColor: 'rgb(121, 137, 255)',
|
||||||
style={{
|
width: '20px',
|
||||||
width: size,
|
height: '20px',
|
||||||
height: size,
|
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||||
}}
|
}}
|
||||||
className={cn('relative flex items-center justify-center', className ?? '')}
|
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||||
>
|
>
|
||||||
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
|
<UserIcon />
|
||||||
imageError ? (
|
</div>
|
||||||
renderDefaultAvatar()
|
)}
|
||||||
) : (
|
</div>
|
||||||
<img
|
);
|
||||||
className="rounded-full"
|
},
|
||||||
src={(user?.avatar ?? '') || avatarSrc}
|
);
|
||||||
alt="avatar"
|
|
||||||
onError={handleImageError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
UserAvatar.displayName = 'UserAvatar';
|
UserAvatar.displayName = 'UserAvatar';
|
||||||
|
|
||||||
|
|
@ -74,9 +112,10 @@ const Icon: React.FC<IconProps> = memo((props) => {
|
||||||
return (
|
return (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={size}
|
size={size}
|
||||||
user={user}
|
|
||||||
avatarSrc={avatarSrc}
|
avatarSrc={avatarSrc}
|
||||||
username={username}
|
username={username}
|
||||||
|
userId={user?.id ?? ''}
|
||||||
|
avatar={user?.avatar ?? ''}
|
||||||
className={props.className}
|
className={props.className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ const ContentRender = memo(function ContentRender({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
<ContentParts
|
<ContentParts
|
||||||
edit={edit}
|
edit={edit}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export default function Message(props: TMessageProps) {
|
||||||
>
|
>
|
||||||
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
|
||||||
<div className="flex-col gap-1 md:gap-3">
|
<div className="flex-col gap-1 md:gap-3">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
<MessageContext.Provider
|
<MessageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
messageId,
|
messageId,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ jest.mock('~/data-provider', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/hooks/useLocalize', () => {
|
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 = {};
|
fn.TranslationKeys = {};
|
||||||
return { __esModule: true, default: fn, TranslationKeys: {} };
|
return { __esModule: true, default: fn, TranslationKeys: {} };
|
||||||
});
|
});
|
||||||
|
|
@ -87,6 +89,8 @@ jest.mock('../useUpdateFiles', () => ({
|
||||||
jest.mock('~/utils', () => ({
|
jest.mock('~/utils', () => ({
|
||||||
logger: { log: jest.fn() },
|
logger: { log: jest.fn() },
|
||||||
validateFiles: jest.fn(() => true),
|
validateFiles: jest.fn(() => true),
|
||||||
|
cachePreview: jest.fn(),
|
||||||
|
getCachedPreview: jest.fn(() => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockValidateFiles = jest.requireMock('~/utils').validateFiles;
|
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 () => {
|
it('falls back to "default" when no conversation endpoint and no override', async () => {
|
||||||
mockConversation = {
|
mockConversation = {
|
||||||
conversationId: Constants.NEW_CONVO,
|
conversationId: Constants.NEW_CONVO as string,
|
||||||
endpoint: null,
|
endpoint: null,
|
||||||
endpointType: undefined,
|
endpointType: undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type * as t from 'librechat-data-provider';
|
||||||
import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
||||||
import type { ExtendedFile, GenericSetter } from '~/common';
|
import type { ExtendedFile, GenericSetter } from '~/common';
|
||||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||||
|
import { deletePreview } from '~/utils';
|
||||||
|
|
||||||
type FileMapSetter = GenericSetter<Map<string, ExtendedFile>>;
|
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) {
|
if (attached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +131,11 @@ const useFileDeletion = ({
|
||||||
temp_file_id,
|
temp_file_id,
|
||||||
embedded: embedded ?? false,
|
embedded: embedded ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deletePreview(file_id);
|
||||||
|
if (temp_file_id) {
|
||||||
|
deletePreview(temp_file_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setFiles) {
|
if (setFiles) {
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,13 @@ import debounce from 'lodash/debounce';
|
||||||
import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider';
|
import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||||
import type { ExtendedFile, FileSetter } from '~/common';
|
import type { ExtendedFile, FileSetter } from '~/common';
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
import type { TConversation } from 'librechat-data-provider';
|
||||||
|
import { logger, validateFiles, cachePreview, getCachedPreview, removePreviewEntry } from '~/utils';
|
||||||
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||||
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||||
import { processFileForUpload } from '~/utils/heicConverter';
|
import { processFileForUpload } from '~/utils/heicConverter';
|
||||||
import { useChatContext } from '~/Providers/ChatContext';
|
import { useChatContext } from '~/Providers/ChatContext';
|
||||||
import { ephemeralAgentByConvoId } from '~/store';
|
import { ephemeralAgentByConvoId } from '~/store';
|
||||||
import { logger, validateFiles } from '~/utils';
|
|
||||||
import useClientResize from './useClientResize';
|
import useClientResize from './useClientResize';
|
||||||
import useUpdateFiles from './useUpdateFiles';
|
import useUpdateFiles from './useUpdateFiles';
|
||||||
|
|
||||||
|
|
@ -130,6 +130,11 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
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(
|
updateFileById(
|
||||||
data.temp_file_id,
|
data.temp_file_id,
|
||||||
{
|
{
|
||||||
|
|
@ -260,7 +265,6 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
||||||
replaceFile(extendedFile);
|
replaceFile(extendedFile);
|
||||||
|
|
||||||
await startUpload(extendedFile);
|
await startUpload(extendedFile);
|
||||||
URL.revokeObjectURL(preview);
|
|
||||||
};
|
};
|
||||||
img.src = preview;
|
img.src = preview;
|
||||||
};
|
};
|
||||||
|
|
@ -301,6 +305,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
||||||
try {
|
try {
|
||||||
// Create initial preview with original file
|
// Create initial preview with original file
|
||||||
const initialPreview = URL.createObjectURL(originalFile);
|
const initialPreview = URL.createObjectURL(originalFile);
|
||||||
|
cachePreview(file_id, initialPreview);
|
||||||
|
|
||||||
// Create initial ExtendedFile to show immediately
|
// Create initial ExtendedFile to show immediately
|
||||||
const initialExtendedFile: ExtendedFile = {
|
const initialExtendedFile: ExtendedFile = {
|
||||||
|
|
@ -378,6 +383,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
||||||
if (finalProcessedFile !== originalFile) {
|
if (finalProcessedFile !== originalFile) {
|
||||||
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||||
const newPreview = URL.createObjectURL(finalProcessedFile);
|
const newPreview = URL.createObjectURL(finalProcessedFile);
|
||||||
|
cachePreview(file_id, newPreview);
|
||||||
|
|
||||||
const updatedExtendedFile: ExtendedFile = {
|
const updatedExtendedFile: ExtendedFile = {
|
||||||
...initialExtendedFile,
|
...initialExtendedFile,
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,23 @@ export default function useEventHandlers({
|
||||||
} else if (requestMessage != null && responseMessage != null) {
|
} else if (requestMessage != null && responseMessage != null) {
|
||||||
finalMessages = [...messages, requestMessage, responseMessage];
|
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) {
|
if (finalMessages.length > 0) {
|
||||||
setFinalMessages(conversation.conversationId, finalMessages);
|
setFinalMessages(conversation.conversationId, finalMessages);
|
||||||
} else if (
|
} else if (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export * from './resources';
|
||||||
export * from './roles';
|
export * from './roles';
|
||||||
export * from './localStorage';
|
export * from './localStorage';
|
||||||
export * from './promptGroups';
|
export * from './promptGroups';
|
||||||
|
export * from './previewCache';
|
||||||
export * from './email';
|
export * from './email';
|
||||||
export * from './share';
|
export * from './share';
|
||||||
export * from './timestamps';
|
export * from './timestamps';
|
||||||
|
|
|
||||||
35
client/src/utils/previewCache.ts
Normal file
35
client/src/utils/previewCache.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Module-level cache for local blob preview URLs keyed by file_id.
|
||||||
|
* Survives message replacements from SSE but clears on page refresh.
|
||||||
|
*/
|
||||||
|
const previewCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function cachePreview(fileId: string, previewUrl: string): void {
|
||||||
|
const existing = previewCache.get(fileId);
|
||||||
|
if (existing && existing !== previewUrl) {
|
||||||
|
URL.revokeObjectURL(existing);
|
||||||
|
}
|
||||||
|
previewCache.set(fileId, previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedPreview(fileId: string): string | undefined {
|
||||||
|
return previewCache.get(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the cache entry without revoking the blob (used when transferring between keys) */
|
||||||
|
export function removePreviewEntry(fileId: string): void {
|
||||||
|
previewCache.delete(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePreview(fileId: string): void {
|
||||||
|
const url = previewCache.get(fileId);
|
||||||
|
if (url) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
previewCache.delete(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPreviewCache(): void {
|
||||||
|
previewCache.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
previewCache.clear();
|
||||||
|
}
|
||||||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -445,7 +445,6 @@
|
||||||
"react-gtm-module": "^2.0.11",
|
"react-gtm-module": "^2.0.11",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-lazy-load-image-component": "^1.6.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
|
@ -500,6 +499,7 @@
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jest-file-loader": "^1.0.3",
|
"jest-file-loader": "^1.0.3",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
|
"monaco-editor": "^0.55.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-preset-env": "^11.2.0",
|
"postcss-preset-env": "^11.2.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
|
@ -32359,11 +32359,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.throttle": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
|
||||||
},
|
|
||||||
"node_modules/lodash.uniq": {
|
"node_modules/lodash.uniq": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||||
|
|
@ -34312,7 +34307,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "3.2.7",
|
"dompurify": "3.2.7",
|
||||||
"marked": "14.0.0"
|
"marked": "14.0.0"
|
||||||
|
|
@ -34323,7 +34317,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"peer": true,
|
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
|
|
@ -34333,7 +34326,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
|
|
@ -38142,19 +38134,6 @@
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-lazy-load-image-component": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-lazy-load-image-component/-/react-lazy-load-image-component-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8KFkDTgjh+0+PVbH+cx0AgxLGbdTsxWMnxXzU5HEUztqewk9ufQAu8cstjZhyvtMIPsdMcPZfA0WAa7HtjQbBQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash.debounce": "^4.0.8",
|
|
||||||
"lodash.throttle": "^4.1.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x",
|
|
||||||
"react-dom": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-lifecycles-compat": {
|
"node_modules/react-lifecycles-compat": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue