mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge branch 'main' into feat/custom-reranker-provider
This commit is contained in:
commit
7a009edb8e
39 changed files with 1090 additions and 676 deletions
|
|
@ -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 #
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -51,7 +51,9 @@ jest.mock('~/data-provider', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/hooks/useLocalize', () => {
|
||||
const fn = jest.fn((key: string) => key);
|
||||
const fn = jest.fn((key: string) => key) as jest.Mock & {
|
||||
TranslationKeys: Record<string, never>;
|
||||
};
|
||||
fn.TranslationKeys = {};
|
||||
return { __esModule: true, default: fn, TranslationKeys: {} };
|
||||
});
|
||||
|
|
@ -87,6 +89,8 @@ jest.mock('../useUpdateFiles', () => ({
|
|||
jest.mock('~/utils', () => ({
|
||||
logger: { log: jest.fn() },
|
||||
validateFiles: jest.fn(() => true),
|
||||
cachePreview: jest.fn(),
|
||||
getCachedPreview: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
const mockValidateFiles = jest.requireMock('~/utils').validateFiles;
|
||||
|
|
@ -263,7 +267,7 @@ describe('useFileHandling', () => {
|
|||
|
||||
it('falls back to "default" when no conversation endpoint and no override', async () => {
|
||||
mockConversation = {
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: null,
|
||||
endpointType: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type * as t from 'librechat-data-provider';
|
|||
import type { UseMutateAsyncFunction } from '@tanstack/react-query';
|
||||
import type { ExtendedFile, GenericSetter } from '~/common';
|
||||
import useSetFilesToDelete from './useSetFilesToDelete';
|
||||
import { deletePreview } from '~/utils';
|
||||
|
||||
type FileMapSetter = GenericSetter<Map<string, ExtendedFile>>;
|
||||
|
||||
|
|
@ -88,6 +89,11 @@ const useFileDeletion = ({
|
|||
});
|
||||
}
|
||||
|
||||
deletePreview(file_id);
|
||||
if (temp_file_id) {
|
||||
deletePreview(temp_file_id);
|
||||
}
|
||||
|
||||
if (attached) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -125,6 +131,11 @@ const useFileDeletion = ({
|
|||
temp_file_id,
|
||||
embedded: embedded ?? false,
|
||||
});
|
||||
|
||||
deletePreview(file_id);
|
||||
if (temp_file_id) {
|
||||
deletePreview(temp_file_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (setFiles) {
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ import debounce from 'lodash/debounce';
|
|||
import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider';
|
||||
import type { ExtendedFile, FileSetter } from '~/common';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { logger, validateFiles, cachePreview, getCachedPreview, removePreviewEntry } from '~/utils';
|
||||
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import { processFileForUpload } from '~/utils/heicConverter';
|
||||
import { useChatContext } from '~/Providers/ChatContext';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { logger, validateFiles } from '~/utils';
|
||||
import useClientResize from './useClientResize';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
|
|
@ -130,6 +130,11 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const cachedBlob = getCachedPreview(data.temp_file_id);
|
||||
if (cachedBlob && data.file_id !== data.temp_file_id) {
|
||||
cachePreview(data.file_id, cachedBlob);
|
||||
removePreviewEntry(data.temp_file_id);
|
||||
}
|
||||
updateFileById(
|
||||
data.temp_file_id,
|
||||
{
|
||||
|
|
@ -260,7 +265,6 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
replaceFile(extendedFile);
|
||||
|
||||
await startUpload(extendedFile);
|
||||
URL.revokeObjectURL(preview);
|
||||
};
|
||||
img.src = preview;
|
||||
};
|
||||
|
|
@ -301,6 +305,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
try {
|
||||
// Create initial preview with original file
|
||||
const initialPreview = URL.createObjectURL(originalFile);
|
||||
cachePreview(file_id, initialPreview);
|
||||
|
||||
// Create initial ExtendedFile to show immediately
|
||||
const initialExtendedFile: ExtendedFile = {
|
||||
|
|
@ -378,6 +383,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil
|
|||
if (finalProcessedFile !== originalFile) {
|
||||
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||
const newPreview = URL.createObjectURL(finalProcessedFile);
|
||||
cachePreview(file_id, newPreview);
|
||||
|
||||
const updatedExtendedFile: ExtendedFile = {
|
||||
...initialExtendedFile,
|
||||
|
|
|
|||
|
|
@ -526,6 +526,23 @@ export default function useEventHandlers({
|
|||
} else if (requestMessage != null && responseMessage != null) {
|
||||
finalMessages = [...messages, requestMessage, responseMessage];
|
||||
}
|
||||
|
||||
/* Preserve files from current messages when server response lacks them */
|
||||
if (finalMessages.length > 0) {
|
||||
const currentMsgMap = new Map(
|
||||
currentMessages
|
||||
.filter((m) => m.files && m.files.length > 0)
|
||||
.map((m) => [m.messageId, m.files]),
|
||||
);
|
||||
for (let i = 0; i < finalMessages.length; i++) {
|
||||
const msg = finalMessages[i];
|
||||
const preservedFiles = currentMsgMap.get(msg.messageId);
|
||||
if (msg.files == null && preservedFiles) {
|
||||
finalMessages[i] = { ...msg, files: preservedFiles };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalMessages.length > 0) {
|
||||
setFinalMessages(conversation.conversationId, finalMessages);
|
||||
} else if (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
|
|
@ -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
97
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue