Merge branch 'main' into feat/custom-reranker-provider

This commit is contained in:
Pascal Garber 2026-03-07 09:05:52 +01:00 committed by GitHub
commit 7a009edb8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1090 additions and 676 deletions

View file

@ -677,7 +677,8 @@ AZURE_CONTAINER_NAME=files
#========================#
ALLOW_SHARED_LINKS=true
ALLOW_SHARED_LINKS_PUBLIC=true
# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
ALLOW_SHARED_LINKS_PUBLIC=false
#==============================#
# Static File Cache Control #

View file

@ -63,7 +63,7 @@
"eventsource": "^3.0.2",
"express": "^5.2.1",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^8.2.1",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.2",
"express-static-gzip": "^2.2.0",
"file-type": "^18.7.0",

View file

@ -16,9 +16,7 @@ const sharedLinksEnabled =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
const publicSharedLinksEnabled =
sharedLinksEnabled &&
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);

View file

@ -19,9 +19,7 @@ const allowSharedLinks =
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
if (allowSharedLinks) {
const allowSharedLinksPublic =
process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
router.get(
'/:shareId',
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,

View file

@ -38,6 +38,7 @@
"@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^5.7.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
@ -93,7 +94,6 @@
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.30.3",
@ -148,6 +148,7 @@
"jest-environment-jsdom": "^30.2.0",
"jest-file-loader": "^1.0.3",
"jest-junit": "^16.0.0",
"monaco-editor": "^0.55.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^11.2.0",
"tailwindcss": "^3.4.1",

View file

@ -1,206 +1,326 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import debounce from 'lodash/debounce';
import { KeyBinding } from '@codemirror/view';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import {
useSandpack,
SandpackCodeEditor,
SandpackProvider as StyledProvider,
} from '@codesandbox/sandpack-react';
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import MonacoEditor from '@monaco-editor/react';
import type { Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditArtifact } from '~/data-provider';
const CodeEditor = memo(
({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const { setCurrentCode } = useCodeState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const LANG_MAP: Record<string, string> = {
javascript: 'javascript',
typescript: 'typescript',
python: 'python',
css: 'css',
json: 'json',
markdown: 'markdown',
html: 'html',
xml: 'xml',
sql: 'sql',
yaml: 'yaml',
shell: 'shell',
bash: 'shell',
tsx: 'typescript',
jsx: 'javascript',
c: 'c',
cpp: 'cpp',
java: 'java',
go: 'go',
rust: 'rust',
kotlin: 'kotlin',
swift: 'swift',
php: 'php',
ruby: 'ruby',
r: 'r',
lua: 'lua',
scala: 'scala',
perl: 'perl',
};
/**
* Create stable debounced mutation that doesn't depend on changing callbacks
* Use refs to always access the latest values without recreating the debounce
*/
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const TYPE_MAP: Record<string, string> = {
'text/html': 'html',
'application/vnd.code-html': 'html',
'application/vnd.react': 'typescript',
'application/vnd.ant.react': 'typescript',
'text/markdown': 'markdown',
'text/md': 'markdown',
'text/plain': 'plaintext',
'application/vnd.mermaid': 'markdown',
};
useEffect(() => {
artifactRef.current = artifact;
}, [artifact]);
function getMonacoLanguage(type?: string, language?: string): string {
if (language && LANG_MAP[language]) {
return LANG_MAP[language];
}
return TYPE_MAP[type ?? ''] ?? 'plaintext';
}
useEffect(() => {
isMutatingRef.current = isMutating;
}, [isMutating]);
useEffect(() => {
currentUpdateRef.current = currentUpdate;
}, [currentUpdate]);
useEffect(() => {
editArtifactRef.current = editArtifact;
}, [editArtifact]);
useEffect(() => {
setCurrentCodeRef.current = setCurrentCode;
}, [setCurrentCode]);
/**
* Create debounced mutation once - never recreate it
* All values are accessed via refs so they're always current
*/
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly) {
return;
}
if (isMutatingRef.current) {
return;
}
if (artifactRef.current.index == null) {
return;
}
const artifact = artifactRef.current;
const artifactIndex = artifact.index;
const isNotOriginal =
code && artifact.content != null && code.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdateRef.current == null
? true
: code != null && code.trim() !== currentUpdateRef.current.trim();
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: artifactIndex,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: code,
});
}
}, 500),
[readOnly],
);
/**
* Listen to Sandpack file changes and trigger debounced mutation
*/
useEffect(() => {
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode) {
debouncedMutation(currentCode);
}
}, [sandpack.files, fileKey, debouncedMutation]);
/**
* Cleanup: cancel pending mutations when component unmounts or artifact changes
*/
useEffect(() => {
return () => {
debouncedMutation.cancel();
};
}, [artifact.id, debouncedMutation]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
extensions={[autocompletion()]}
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
className="hljs language-javascript bg-black"
/>
);
},
);
export const ArtifactCodeEditor = function ({
files,
fileKey,
template,
export const ArtifactCodeEditor = function ArtifactCodeEditor({
artifact,
editorRef,
sharedProps,
monacoRef,
readOnly: externalReadOnly,
}: {
fileKey: string;
artifact: Artifact;
files: ArtifactFiles;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
readOnly?: boolean;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
const options: typeof sharedOptions = useMemo(() => {
if (!config) {
return sharedOptions;
}
return {
...sharedOptions,
activeFile: '/' + fileKey,
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template, fileKey]);
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
const [readOnly, setReadOnly] = useState(initialReadOnly);
useEffect(() => {
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
}, [isSubmitting, externalReadOnly]);
const readOnly = (externalReadOnly ?? false) || isSubmitting;
const { setCurrentCode } = useCodeState();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
if (Object.keys(files).length === 0) {
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const prevContentRef = useRef(artifact.content ?? '');
const prevArtifactId = useRef(artifact.id);
const prevReadOnly = useRef(readOnly);
artifactRef.current = artifact;
isMutatingRef.current = isMutating;
currentUpdateRef.current = currentUpdate;
editArtifactRef.current = editArtifact;
setCurrentCodeRef.current = setCurrentCode;
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
return;
}
const art = artifactRef.current;
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
const isNotRepeated =
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: art.index,
messageId: art.messageId ?? '',
original: art.content,
updated: code,
});
}
}, 500),
[readOnly],
);
useEffect(() => {
return () => debouncedMutation.cancel();
}, [artifact.id, debouncedMutation]);
/**
* Streaming: use model.applyEdits() to append new content.
* Unlike setValue/pushEditOperations, applyEdits preserves existing
* tokens so syntax highlighting doesn't flash during updates.
*/
useEffect(() => {
const ed = monacoRef.current;
if (!ed || !readOnly) {
return;
}
const newContent = artifact.content ?? '';
const prev = prevContentRef.current;
if (newContent === prev) {
return;
}
const model = ed.getModel();
if (!model) {
return;
}
if (newContent.startsWith(prev) && prev.length > 0) {
const appended = newContent.slice(prev.length);
const endPos = model.getPositionAt(model.getValueLength());
model.applyEdits([
{
range: {
startLineNumber: endPos.lineNumber,
startColumn: endPos.column,
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
},
text: appended,
},
]);
} else {
model.setValue(newContent);
}
prevContentRef.current = newContent;
ed.revealLine(model.getLineCount());
}, [artifact.content, readOnly, monacoRef]);
useEffect(() => {
if (artifact.id === prevArtifactId.current) {
return;
}
prevArtifactId.current = artifact.id;
prevContentRef.current = artifact.content ?? '';
const ed = monacoRef.current;
if (ed && artifact.content != null) {
ed.getModel()?.setValue(artifact.content);
}
}, [artifact.id, artifact.content, monacoRef]);
useEffect(() => {
if (prevReadOnly.current && !readOnly && artifact.content != null) {
const ed = monacoRef.current;
if (ed) {
ed.getModel()?.setValue(artifact.content);
prevContentRef.current = artifact.content;
}
}
prevReadOnly.current = readOnly;
}, [readOnly, artifact.content, monacoRef]);
const handleChange = useCallback(
(value: string | undefined) => {
if (value === undefined || readOnly) {
return;
}
prevContentRef.current = value;
setCurrentCode(value);
if (value.length > 0) {
debouncedMutation(value);
}
},
[readOnly, debouncedMutation, setCurrentCode],
);
/**
* Disable all validation this is an artifact viewer/editor, not an IDE.
* Note: these are global Monaco settings that affect all editor instances on the page.
* The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript`
* as `{ deprecated: true }` while the runtime API is fully functional.
*/
const handleBeforeMount = useCallback((monaco: Monaco) => {
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
.typescript as unknown as {
typescriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
javascriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
JsxEmit: { React: number };
};
const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true };
const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React };
typescriptDefaults.setDiagnosticsOptions(diagnosticsOff);
javascriptDefaults.setDiagnosticsOptions(diagnosticsOff);
typescriptDefaults.setCompilerOptions(compilerBase);
javascriptDefaults.setCompilerOptions(compilerBase);
}, []);
const handleMount = useCallback(
(ed: editor.IStandaloneCodeEditor) => {
monacoRef.current = ed;
prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? '';
if (readOnly) {
const model = ed.getModel();
if (model) {
ed.revealLine(model.getLineCount());
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[monacoRef],
);
const language = getMonacoLanguage(artifact.type, artifact.language);
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
() => ({
readOnly,
minimap: { enabled: false },
lineNumbers: 'on',
scrollBeyondLastLine: false,
fontSize: 13,
tabSize: 2,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8 },
renderLineHighlight: readOnly ? 'none' : 'line',
cursorStyle: readOnly ? 'underline-thin' : 'line',
scrollbar: {
vertical: 'visible',
horizontal: 'auto',
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
useShadows: false,
alwaysConsumeMouseWheel: false,
},
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
folding: false,
glyphMargin: false,
colorDecorators: !readOnly,
occurrencesHighlight: readOnly ? 'off' : 'singleFile',
selectionHighlight: !readOnly,
renderValidationDecorations: readOnly ? 'off' : 'editable',
quickSuggestions: !readOnly,
suggestOnTriggerCharacters: !readOnly,
parameterHints: { enabled: !readOnly },
hover: { enabled: !readOnly },
matchBrackets: readOnly ? 'never' : 'always',
}),
[readOnly],
);
if (!artifact.content) {
return null;
}
return (
<StyledProvider
theme="dark"
files={{
...files,
...sharedFiles,
}}
options={options}
{...sharedProps}
template={template}
>
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
</StyledProvider>
<div className="h-full w-full bg-[#1e1e1e]">
<MonacoEditor
height="100%"
language={language}
theme="vs-dark"
defaultValue={artifact.content}
onChange={handleChange}
beforeMount={handleBeforeMount}
onMount={handleMount}
options={editorOptions}
/>
</div>
);
};

View file

@ -1,30 +1,26 @@
import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
export default function ArtifactTabs({
artifact,
editorRef,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null);
useEffect(() => {
@ -34,33 +30,24 @@ export default function ArtifactTabs({
lastIdRef.current = artifact.id;
}, [setCurrentCode, artifact.id]);
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<div className="flex h-full w-full flex-col">
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
readOnly={isSharedConvo}
/>
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
</Tabs.Content>
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
<Tabs.Content
value="preview"
className="h-full w-full flex-grow overflow-hidden"
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}

View file

@ -3,7 +3,7 @@ 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, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
@ -22,7 +22,6 @@ export default function Artifacts() {
const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@ -297,7 +296,6 @@ export default function Artifacts() {
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
/>

View file

@ -1,11 +1,8 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import React, { memo, useState } from 'react';
import copy from 'copy-to-clipboard';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import { Button } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick, langSubset } from '~/utils';
import { handleDoubleClick } from '~/utils';
import { useLocalize } from '~/hooks';
type TCodeProps = {
@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
});
export const CodeMarkdown = memo(
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled]);
return (
<div ref={scrollRef} className="max-h-full overflow-y-auto">
<ReactMarkdown
/* @ts-ignore */
rehypePlugins={rehypePlugins}
components={
{ code } as {
[key: string]: React.ElementType;
}
}
>
{currentContent}
</ReactMarkdown>
</div>
);
},
);
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);

View file

@ -35,6 +35,11 @@ function Header() {
permission: Permissions.USE,
});
const hasAccessToTemporaryChat = useHasAccess({
permissionType: PermissionTypes.TEMPORARY_CHAT,
permission: Permissions.USE,
});
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
@ -73,7 +78,7 @@ function Header() {
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
{hasAccessToTemporaryChat === true && <TemporaryChat />}
</>
)}
</div>
@ -85,7 +90,7 @@ function Header() {
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
/>
<TemporaryChat />
{hasAccessToTemporaryChat === true && <TemporaryChat />}
</div>
)}
</div>

View file

@ -3,10 +3,10 @@ import { useToastContext } from '@librechat/client';
import { EToolResources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import { logger, getCachedPreview } from '~/utils';
import { useFileDeletion } from '~/hooks/Files';
import FileContainer from './FileContainer';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
import Image from './Image';
export default function FileRow({
@ -112,13 +112,15 @@ export default function FileRow({
)
.uniqueFiles.map((file: ExtendedFile, index: number) => {
const handleDelete = () => {
showToast({
message: localize('com_ui_deleting_file'),
status: 'info',
});
if (abortUpload && file.progress < 1) {
abortUpload();
}
if (file.progress >= 1) {
showToast({
message: localize('com_ui_deleting_file'),
status: 'info',
});
}
deleteFile({ file, setFiles });
};
const isImage = file.type?.startsWith('image') ?? false;
@ -134,7 +136,7 @@ export default function FileRow({
>
{isImage ? (
<Image
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
url={getCachedPreview(file.file_id) ?? file.preview ?? file.filepath}
onDelete={handleDelete}
progress={file.progress}
source={file.source}

View file

@ -21,6 +21,7 @@ jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
},
getCachedPreview: jest.fn(() => undefined),
}));
jest.mock('../Image', () => {
@ -95,7 +96,7 @@ describe('FileRow', () => {
};
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({
file_id: 'uploaded-file',
preview: 'blob:http://localhost:3080/temp-preview',
@ -109,8 +110,7 @@ describe('FileRow', () => {
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
expect(imageUrl).not.toContain('blob:');
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
});
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');
});
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({
file_id: 'complete-file',
preview: 'blob:http://localhost:3080/old-blob',
@ -161,7 +161,7 @@ describe('FileRow', () => {
renderFileRow(filesMap);
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);
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', () => {
@ -321,10 +321,10 @@ describe('FileRow', () => {
});
});
describe('Regression: Blob URL Bug Fix', () => {
it('should NOT use revoked blob URL after upload completes', () => {
describe('Preview Cache Integration', () => {
it('should prefer preview blob URL over filepath for zero-flicker rendering', () => {
const file = createMockFile({
file_id: 'regression-test',
file_id: 'cache-test',
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
@ -337,8 +337,24 @@ describe('FileRow', () => {
renderFileRow(filesMap);
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(
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
);

View file

@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client';
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
import { useLocalize } from '~/hooks';
const imageSizeCache = new Map<string, string>();
const getQualityStyles = (quality: string): string => {
if (quality === 'high') {
return 'bg-green-100 text-green-800';
@ -50,18 +52,26 @@ export default function DialogImage({
const closeButtonRef = useRef<HTMLButtonElement>(null);
const getImageSize = useCallback(async (url: string) => {
const cached = imageSizeCache.get(url);
if (cached) {
return cached;
}
try {
const response = await fetch(url, { method: 'HEAD' });
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
const bytes = parseInt(contentLength, 10);
return formatFileSize(bytes);
const result = formatFileSize(bytes);
imageSizeCache.set(url, result);
return result;
}
const fullResponse = await fetch(url);
const blob = await fullResponse.blob();
return formatFileSize(blob.size);
const result = formatFileSize(blob.size);
imageSizeCache.set(url, result);
return result;
} catch (error) {
console.error('Error getting image size:', error);
return null;
@ -355,6 +365,7 @@ export default function DialogImage({
ref={imageRef}
src={src}
alt="Image"
decoding="async"
className="block max-h-[85vh] object-contain"
style={{
maxWidth: getImageMaxWidth(),

View file

@ -1,6 +1,7 @@
import { useMemo, memo } from 'react';
import type { TFile, TMessage } from 'librechat-data-provider';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
import { getCachedPreview } from '~/utils';
import Image from './Image';
const Files = ({ message }: { message?: TMessage }) => {
@ -17,21 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => {
{otherFiles.length > 0 &&
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
{imageFiles.length > 0 &&
imageFiles.map((file) => (
<Image
key={file.file_id}
imagePath={file.preview ?? file.filepath ?? ''}
height={file.height ?? 1920}
width={file.width ?? 1080}
altText={file.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: `${file.height ?? 1920}px`,
width: `${file.height ?? 1080}px`,
}}
// n={imageFiles.length}
// i={i}
/>
))}
imageFiles.map((file) => {
const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
return (
<Image
key={file.file_id}
width={file.width}
height={file.height}
altText={file.filename ?? 'Uploaded Image'}
imagePath={cached ?? file.preview ?? file.filepath ?? ''}
/>
);
})}
</>
);
};

View file

@ -1,27 +1,39 @@
import React, { useState, useRef, useMemo } from 'react';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage';
import { cn } from '~/utils';
/** Max display height for chat images (Tailwind JIT class) */
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 = ({
imagePath,
altText,
height,
width,
placeholderDimensions,
className,
args,
width,
height,
}: {
imagePath: string;
altText: string;
height: number;
width: number;
placeholderDimensions?: {
height?: string;
width?: string;
};
className?: string;
args?: {
prompt?: string;
@ -30,19 +42,15 @@ const Image = ({
style?: string;
[key: string]: unknown;
};
width?: number;
height?: number;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
// If it's already an absolute URL or doesn't start with /images/, return as is
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
@ -51,21 +59,10 @@ const Image = ({
return imagePath;
}
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
}, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo(
() =>
scaleImage({
originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width),
originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height),
containerRef,
}),
[placeholderDimensions, height, width],
);
const downloadImage = async () => {
try {
const response = await fetch(absoluteImageUrl);
@ -95,8 +92,19 @@ 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 (
<div ref={containerRef}>
<div>
<button
ref={triggerRef}
type="button"
@ -104,45 +112,33 @@ const Image = ({
aria-haspopup="dialog"
onClick={() => setIsOpen(true)}
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',
className,
)}
style={heightStyle}
>
<LazyLoadImage
{showSkeleton && <Skeleton className="absolute inset-0" aria-hidden="true" />}
<img
alt={altText}
onLoad={handleImageLoad}
visibleByDefault={true}
className={cn(
'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
src={absoluteImageUrl}
style={{
width: `${scaledWidth}`,
height: 'auto',
color: 'transparent',
display: 'block',
}}
placeholder={
<Skeleton
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
aria-label="Loading image"
aria-busy="true"
/>
}
onLoad={() => paintedUrls.add(absoluteImageUrl)}
className={cn(
'relative block text-transparent',
hasDimensions
? 'size-full object-contain'
: cn('h-auto w-auto max-w-full', IMAGE_MAX_H),
)}
/>
</button>
{isLoaded && (
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
triggerRef={triggerRef}
/>
)}
<DialogImage
isOpen={isOpen}
onOpenChange={setIsOpen}
src={absoluteImageUrl}
downloadImage={downloadImage}
args={args}
triggerRef={triggerRef}
/>
</div>
);
};

View file

@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
import { ErrorMessage } from './MessageContent';
import RetrievalCall from './RetrievalCall';
import { getCachedPreview } from '~/utils';
import AgentHandoff from './AgentHandoff';
import CodeAnalyze from './CodeAnalyze';
import Container from './Container';
@ -222,18 +223,13 @@ const Part = memo(function Part({
}
} else if (part.type === ContentTypes.IMAGE_FILE) {
const imageFile = part[ContentTypes.IMAGE_FILE];
const height = imageFile.height ?? 1920;
const width = imageFile.width ?? 1080;
const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined;
return (
<Image
imagePath={imageFile.filepath}
height={height}
width={width}
imagePath={cached ?? imageFile.filepath}
altText={imageFile.filename ?? 'Uploaded Image'}
placeholderDimensions={{
height: height + 'px',
width: width + 'px',
}}
width={imageFile.width}
height={imageFile.height}
/>
);
}

View file

@ -76,8 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
<Image
altText={attachment.filename || 'attachment image'}
imagePath={filepath ?? ''}
height={height ?? 0}
width={width ?? 0}
width={width}
height={height}
className="mb-4"
/>
</div>

View file

@ -1,8 +1,9 @@
import { memo } from 'react';
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
const EmptyTextPart = memo(() => {
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="absolute">
<p className="submitting relative">

View file

@ -12,11 +12,7 @@ interface LogContentProps {
attachments?: TAttachment[];
}
type ImageAttachment = TFile &
TAttachmentMetadata & {
height: number;
width: number;
};
type ImageAttachment = TFile & TAttachmentMetadata;
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
const localize = useLocalize();
@ -35,12 +31,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
const nonImageAtts: TAttachment[] = [];
attachments?.forEach((attachment) => {
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage =
imageExtRegex.test(attachment.filename ?? '') &&
width != null &&
height != null &&
filepath != null;
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
if (isImage) {
imageAtts.push(attachment as ImageAttachment);
} else {
@ -100,18 +92,15 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
))}
</div>
)}
{imageAttachments?.map((attachment, index) => {
const { width, height, filepath } = attachment;
return (
<Image
key={index}
altText={attachment.filename}
imagePath={filepath}
height={height}
width={width}
/>
);
})}
{imageAttachments?.map((attachment) => (
<Image
width={attachment.width}
height={attachment.height}
key={attachment.filepath}
altText={attachment.filename}
imagePath={attachment.filepath}
/>
))}
</>
);
};

View file

@ -1,9 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef } from 'react';
import { PixelCard } from '@librechat/client';
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
import Image from '~/components/Chat/Messages/Content/Image';
import ProgressText from './ProgressText';
import { scaleImage } from '~/utils';
import { cn } from '~/utils';
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
const IMAGE_FULL_H = 'h-[45vh]' as const;
export default function OpenAIImageGen({
initialProgress = 0.1,
@ -28,8 +31,6 @@ export default function OpenAIImageGen({
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
let width: number | undefined;
let height: number | undefined;
let quality: 'low' | 'medium' | 'high' = 'high';
// Parse args if it's a string
@ -41,62 +42,21 @@ export default function OpenAIImageGen({
parsedArgs = {};
}
try {
const argsObj = parsedArgs;
if (argsObj && typeof argsObj.size === 'string') {
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
if (!isNaN(w) && !isNaN(h)) {
width = w;
height = h;
}
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
width = undefined;
height = undefined;
if (parsedArgs && typeof parsedArgs.quality === 'string') {
const q = parsedArgs.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
if (argsObj && typeof argsObj.quality === 'string') {
const q = argsObj.quality.toLowerCase();
if (q === 'low' || q === 'medium' || q === 'high') {
quality = q;
}
}
} catch (e) {
width = undefined;
height = undefined;
}
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
const attachment = attachments?.[0];
const {
width: imageWidth,
height: imageHeight,
filepath = null,
filename = '',
width: imgWidth,
height: imgHeight,
} = (attachment as TFile & TAttachmentMetadata) || {};
let origWidth = width ?? imageWidth;
let origHeight = height ?? imageHeight;
if (origWidth === undefined || origHeight === undefined) {
origWidth = 1024;
origHeight = 1024;
}
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
const containerRef = useRef<HTMLDivElement>(null);
const updateDimensions = useCallback(() => {
if (origWidth && origHeight && containerRef.current) {
const scaled = scaleImage({
originalWidth: origWidth,
originalHeight: origHeight,
containerRef,
});
setDimensions(scaled);
}
}, [origWidth, origHeight]);
useEffect(() => {
if (isSubmitting) {
setProgress(initialProgress);
@ -156,45 +116,21 @@ export default function OpenAIImageGen({
}
}, [initialProgress, cancelled]);
useEffect(() => {
updateDimensions();
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [updateDimensions]);
return (
<>
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
</div>
<div className="relative mb-2 flex w-full justify-start">
<div ref={containerRef} className="w-full max-w-lg">
{dimensions.width !== 'auto' && progress < 1 && (
<PixelCard
variant="default"
progress={progress}
randomness={0.6}
width={dimensions.width}
height={dimensions.height}
/>
)}
<div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
<Image
width={imgWidth}
args={parsedArgs}
height={imgHeight}
altText={filename}
imagePath={filepath ?? ''}
width={Number(dimensions.width?.split('px')[0])}
height={Number(dimensions.height?.split('px')[0])}
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
args={parsedArgs}
className={progress < 1 ? 'invisible absolute' : ''}
/>
</div>
</div>

View file

@ -0,0 +1,179 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Image, { _resetImageCaches } from '../Image';
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined | null)[]) =>
classes
.flat(Infinity)
.filter((c): c is string => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('librechat-data-provider', () => ({
apiBaseUrl: () => '',
}));
jest.mock('@librechat/client', () => ({
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div data-testid="skeleton" className={className} {...props} />
),
}));
jest.mock('../DialogImage', () => ({
__esModule: true,
default: ({ isOpen, src }: { isOpen: boolean; src: string }) =>
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
}));
describe('Image', () => {
const defaultProps = {
imagePath: '/images/test.png',
altText: 'Test image',
};
beforeEach(() => {
_resetImageCaches();
jest.clearAllMocks();
});
describe('rendering without dimensions', () => {
it('renders with max-h-[45vh] height constraint', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('max-h-[45vh]');
});
it('renders with max-w-full to prevent landscape clipping', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('max-w-full');
});
it('renders with w-auto and h-auto for natural aspect ratio', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img.className).toContain('w-auto');
expect(img.className).toContain('h-auto');
});
it('does not show skeleton without dimensions', () => {
render(<Image {...defaultProps} />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
});
it('does not apply heightStyle without dimensions', () => {
render(<Image {...defaultProps} />);
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);
// 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', () => {
render(<Image {...defaultProps} className="mb-4" />);
const button = screen.getByRole('button');
expect(button.className).toContain('mb-4');
});
it('sets correct alt text', () => {
render(<Image {...defaultProps} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('alt', 'Test image');
});
it('has correct accessibility attributes on button', () => {
render(<Image {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'View Test image in dialog');
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
});
});
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', () => {
it('passes /images/ paths through with base URL', () => {
render(<Image {...defaultProps} imagePath="/images/test.png" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/images/test.png');
});
it('passes absolute http URLs through unchanged', () => {
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
});
it('passes data URIs through unchanged', () => {
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
});
it('passes non-/images/ paths through unchanged', () => {
render(<Image {...defaultProps} imagePath="/other/path.png" />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/other/path.png');
});
});
});

View file

@ -0,0 +1,182 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
jest.mock('~/utils', () => ({
cn: (...classes: (string | boolean | undefined | null)[]) =>
classes
.flat(Infinity)
.filter((c): c is string => typeof c === 'string' && c.length > 0)
.join(' '),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
__esModule: true,
default: ({
altText,
imagePath,
className,
}: {
altText: string;
imagePath: string;
className?: string;
}) => (
<div
data-testid="image-component"
data-alt={altText}
data-src={imagePath}
className={className}
/>
),
}));
jest.mock('@librechat/client', () => ({
PixelCard: ({ progress }: { progress: number }) => (
<div data-testid="pixel-card" data-progress={progress} />
),
}));
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
__esModule: true,
default: ({ progress, error }: { progress: number; error: boolean }) => (
<div data-testid="progress-text" data-progress={progress} data-error={String(error)} />
),
}));
describe('OpenAIImageGen', () => {
const defaultProps = {
initialProgress: 0.1,
isSubmitting: true,
toolName: 'image_gen_oai',
args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}',
output: null as string | null,
attachments: undefined,
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('image preloading', () => {
it('keeps Image mounted during generation (progress < 1)', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
it('hides Image with invisible absolute while progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const image = screen.getByTestId('image-component');
expect(image.className).toContain('invisible');
expect(image.className).toContain('absolute');
});
it('shows Image without hiding classes when progress >= 1', () => {
render(
<OpenAIImageGen
{...defaultProps}
initialProgress={1}
isSubmitting={false}
attachments={[
{
filename: 'cat.png',
filepath: '/images/cat.png',
conversationId: 'conv1',
} as never,
]}
/>,
);
const image = screen.getByTestId('image-component');
expect(image.className).not.toContain('invisible');
expect(image.className).not.toContain('absolute');
});
});
describe('PixelCard visibility', () => {
it('shows PixelCard when progress < 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
});
it('hides PixelCard when progress >= 1', () => {
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument();
});
});
describe('layout classes', () => {
it('applies max-h-[45vh] to the outer container', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} />);
const outerDiv = container.querySelector('[class*="max-h-"]');
expect(outerDiv?.className).toContain('max-h-[45vh]');
});
it('applies h-[45vh] w-full to inner container during loading', () => {
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
expect(innerDiv).not.toBeNull();
expect(innerDiv?.className).toContain('w-full');
});
it('applies w-auto to inner container when complete', () => {
const { container } = render(
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
);
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
expect(overflowDiv?.className).toContain('w-auto');
});
});
describe('args parsing', () => {
it('parses quality from args', () => {
render(<OpenAIImageGen {...defaultProps} />);
expect(screen.getByTestId('progress-text')).toBeInTheDocument();
});
it('handles invalid JSON args gracefully', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('handles object args', () => {
render(
<OpenAIImageGen
{...defaultProps}
args={{ prompt: 'a dog', quality: 'low', size: '512x512' }}
/>,
);
expect(screen.getByTestId('image-component')).toBeInTheDocument();
});
});
describe('cancellation', () => {
it('shows error state when output contains error', () => {
render(
<OpenAIImageGen
{...defaultProps}
output="Error processing tool call"
isSubmitting={false}
initialProgress={0.5}
/>,
);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
it('shows cancelled state when not submitting and incomplete', () => {
render(<OpenAIImageGen {...defaultProps} isSubmitting={false} initialProgress={0.5} />);
const progressText = screen.getByTestId('progress-text');
expect(progressText).toHaveAttribute('data-error', 'true');
});
});
});

View file

@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) {
</h2>
)}
<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
edit={edit}
isLast={isLast}
@ -147,7 +147,7 @@ export default function Message(props: TMessageProps) {
/>
</div>
{isLast && isSubmitting ? (
<div className="mt-1 h-[27px] bg-transparent" />
<div className="mt-1 h-[31px] bg-transparent" />
) : (
<SubRow classes="text-xs">
<SiblingSwitch

View file

@ -152,7 +152,7 @@ const MessageRender = memo(function MessageRender({
)}
<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}>
<MessageContent
ask={ask}

View file

@ -1,7 +1,8 @@
import { memo } from 'react';
/** Height matches the SubRow action buttons row (31px) — keep in sync with HoverButtons */
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';

View file

@ -1,64 +1,102 @@
import React, { memo, useState } from 'react';
import React, { memo } from 'react';
import { UserIcon, useAvatar } from '@librechat/client';
import type { TUser } from 'librechat-data-provider';
import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
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 = {
size: number;
user?: TUser;
avatar: string;
avatarSrc: string;
userId: string;
username: string;
className?: string;
};
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
const [imageError, setImageError] = useState(false);
const UserAvatar = memo(
({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => {
const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc));
const handleImageError = () => {
setImageError(true);
};
React.useEffect(() => {
setResolved(resolveAvatar(userId, avatar, avatarSrc));
}, [userId, avatar, avatarSrc]);
const renderDefaultAvatar = () => (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
);
return (
<div
title={username}
style={{
width: size,
height: size,
}}
className={cn('relative flex items-center justify-center', className ?? '')}
>
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
imageError ? (
renderDefaultAvatar()
) : (
<img
className="rounded-full"
src={(user?.avatar ?? '') || avatarSrc}
alt="avatar"
onError={handleImageError}
/>
)}
</div>
);
});
return (
<div
title={username}
style={{ width: size, height: size }}
className={cn('relative flex items-center justify-center', className ?? '')}
>
{resolved.type === 'image' ? (
<img
className="rounded-full"
src={resolved.src}
alt="avatar"
onError={() => setResolved(markAvatarFailed(userId, resolved.src))}
/>
) : (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '20px',
height: '20px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
>
<UserIcon />
</div>
)}
</div>
);
},
);
UserAvatar.displayName = 'UserAvatar';
@ -74,9 +112,10 @@ const Icon: React.FC<IconProps> = memo((props) => {
return (
<UserAvatar
size={size}
user={user}
avatarSrc={avatarSrc}
username={username}
userId={user?.id ?? ''}
avatar={user?.avatar ?? ''}
className={props.className}
/>
);

View file

@ -144,7 +144,7 @@ const ContentRender = memo(function ContentRender({
)}
<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
edit={edit}
isLast={isLast}

View file

@ -69,7 +69,7 @@ export default function Message(props: TMessageProps) {
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<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
value={{
messageId,

View file

@ -1,47 +0,0 @@
// hooks/useAutoScroll.ts
import { useEffect, useState } from 'react';
interface UseAutoScrollProps {
ref: React.RefObject<HTMLElement>;
content: string;
isSubmitting: boolean;
}
export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => {
const [userScrolled, setUserScrolled] = useState(false);
useEffect(() => {
const scrollContainer = ref.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [ref]);
useEffect(() => {
const scrollContainer = ref.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled, ref]);
return { userScrolled };
};

View file

@ -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,
};

View file

@ -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) {

View file

@ -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,

View file

@ -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 (

View file

@ -349,26 +349,6 @@
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] {
scrollbar-gutter: stable !important;
background-color: rgba(205, 205, 205, 0.66) !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar {
width: 12px !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-thumb {
background-color: rgba(56, 56, 56) !important;
border-radius: 6px !important;
border: 2px solid transparent !important;
background-clip: padding-box !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-track {
background-color: transparent !important;
}
.cm-content:focus {
outline: none !important;
}

View file

@ -7,6 +7,7 @@ import type {
const artifactFilename = {
'application/vnd.react': 'App.tsx',
'application/vnd.ant.react': 'App.tsx',
'text/html': 'index.html',
'application/vnd.code-html': 'index.html',
// mermaid and markdown types are handled separately in useArtifactProps.ts
@ -28,6 +29,7 @@ const artifactTemplate: Record<
> = {
'text/html': 'static',
'application/vnd.react': 'react-ts',
'application/vnd.ant.react': 'react-ts',
'application/vnd.mermaid': 'react-ts',
'application/vnd.code-html': 'static',
'text/markdown': 'react-ts',
@ -119,6 +121,7 @@ const dependenciesMap: Record<
> = {
'application/vnd.mermaid': mermaidDependencies,
'application/vnd.react': standardDependencies,
'application/vnd.ant.react': standardDependencies,
'text/html': standardDependencies,
'application/vnd.code-html': standardDependencies,
'text/markdown': markdownDependencies,

View file

@ -24,12 +24,12 @@ export * from './resources';
export * from './roles';
export * from './localStorage';
export * from './promptGroups';
export * from './previewCache';
export * from './email';
export * from './share';
export * from './timestamps';
export { default as cn } from './cn';
export { default as logger } from './logger';
export { default as scaleImage } from './scaleImage';
export { default as getLoginError } from './getLoginError';
export { default as cleanupPreset } from './cleanupPreset';
export { default as buildDefaultConvo } from './buildDefaultConvo';

View 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();
}

View file

@ -1,21 +0,0 @@
export default function scaleImage({
originalWidth,
originalHeight,
containerRef,
}: {
originalWidth?: number;
originalHeight?: number;
containerRef: React.RefObject<HTMLDivElement>;
}) {
const containerWidth = containerRef.current?.offsetWidth ?? 0;
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
return { width: 'auto', height: 'auto' };
}
const aspectRatio = originalWidth / originalHeight;
const scaledWidth = Math.min(containerWidth, originalWidth);
const scaledHeight = scaledWidth / aspectRatio;
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
}

97
package-lock.json generated
View file

@ -78,7 +78,7 @@
"eventsource": "^3.0.2",
"express": "^5.2.1",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^8.2.1",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.2",
"express-static-gzip": "^2.2.0",
"file-type": "^18.7.0",
@ -389,6 +389,7 @@
"@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^5.7.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
@ -444,7 +445,6 @@
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.30.3",
@ -499,6 +499,7 @@
"jest-environment-jsdom": "^30.2.0",
"jest-file-loader": "^1.0.3",
"jest-junit": "^16.0.0",
"monaco-editor": "^0.55.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^11.2.0",
"tailwindcss": "^3.4.1",
@ -12437,6 +12438,29 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz",
@ -27085,12 +27109,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@ -29260,9 +29284,9 @@
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
@ -32335,11 +32359,6 @@
"dev": true,
"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": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@ -34283,6 +34302,37 @@
"node": "*"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/monaco-editor/node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mongodb": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz",
@ -38084,19 +38134,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"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": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
@ -40426,6 +40463,12 @@
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/static-browser-server": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/static-browser-server/-/static-browser-server-1.0.3.tgz",