diff --git a/.env.example b/.env.example index 06d509a3ae..b851749baf 100644 --- a/.env.example +++ b/.env.example @@ -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 # diff --git a/api/package.json b/api/package.json index 7710956701..2f4bca3a0c 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d2..0adc9272bb 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -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); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 6400b8b637..296644afde 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -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, diff --git a/client/package.json b/client/package.json index 0f7ffed04c..c588ccc6d9 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 4ab2b182b8..ddf8dc84fa 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -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; - }) => { - const { sandpack } = useSandpack(); - const [currentUpdate, setCurrentUpdate] = useState(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 = { + 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 = { + '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 ( - (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; - editorRef: React.MutableRefObject; + monacoRef: React.MutableRefObject; 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(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( + () => ({ + 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 ( - - - +
+ +
); }; diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 8e2a92eb9c..32332215f0 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -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; previewRef: React.MutableRefObject; isSharedConvo?: boolean; }) { - const { isSubmitting } = useArtifactsContext(); const { currentCode, setCurrentCode } = useCodeState(); const { data: startupConfig } = useGetStartupConfig(); + const monacoRef = useRef(null); const lastIdRef = useRef(null); useEffect(() => { @@ -34,33 +30,24 @@ export default function ArtifactTabs({ lastIdRef.current = artifact.id; }, [setCurrentCode, artifact.id]); - const content = artifact.content ?? ''; - const contentRef = useRef(null); - useAutoScroll({ ref: contentRef, content, isSubmitting }); - const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); return (
- + - + (); const previewRef = useRef(); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); @@ -297,7 +296,6 @@ export default function Artifacts() {
} previewRef={previewRef as React.MutableRefObject} isSharedConvo={isSharedConvo} /> diff --git a/client/src/components/Artifacts/Code.tsx b/client/src/components/Artifacts/Code.tsx index 6894ce775b..001b010908 100644 --- a/client/src/components/Artifacts/Code.tsx +++ b/client/src/components/Artifacts/Code.tsx @@ -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 {children}; }); -export const CodeMarkdown = memo( - ({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => { - const scrollRef = useRef(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 ( -
- - {currentContent} - -
- ); - }, -); - export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => { const localize = useLocalize(); const [isCopied, setIsCopied] = useState(false); diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 611e96fdcc..9e44e804c9 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -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() { - + {hasAccessToTemporaryChat === true && } )}
@@ -85,7 +90,7 @@ function Header() { - + {hasAccessToTemporaryChat === true && }
)} diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index ba8e73a8a2..bf04b16ade 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -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 ? ( ({ 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(); + 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', ); diff --git a/client/src/components/Chat/Messages/Content/DialogImage.tsx b/client/src/components/Chat/Messages/Content/DialogImage.tsx index cb496de646..b9cbe64555 100644 --- a/client/src/components/Chat/Messages/Content/DialogImage.tsx +++ b/client/src/components/Chat/Messages/Content/DialogImage.tsx @@ -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(); + 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(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(), diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 8997d5e822..504e48e883 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -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) => )} {imageFiles.length > 0 && - imageFiles.map((file) => ( - - ))} + imageFiles.map((file) => { + const cached = file.file_id ? getCachedPreview(file.file_id) : undefined; + return ( + + ); + })} ); }; diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index cd72733298..7e3e12e65b 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -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(); +/** Tracks URLs that have been fully painted — skip skeleton on remount */ +const paintedUrls = new Set(); + +/** 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(null); const triggerRef = useRef(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 ( -
+
- {isLoaded && ( - - )} +
); }; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 55574d576d..7bce7ac11d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -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 ( ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index b5d1e07cbf..31e30772dc 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -76,8 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
diff --git a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx index 1b514164df..409461a058 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EmptyText.tsx @@ -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 ( -
+

diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index da2a8f175e..a675ff06d8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -12,11 +12,7 @@ interface LogContentProps { attachments?: TAttachment[]; } -type ImageAttachment = TFile & - TAttachmentMetadata & { - height: number; - width: number; - }; +type ImageAttachment = TFile & TAttachmentMetadata; const LogContent: React.FC = ({ output = '', renderImages, attachments }) => { const localize = useLocalize(); @@ -35,12 +31,8 @@ const LogContent: React.FC = ({ 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 = ({ output = '', renderImages, atta ))}

)} - {imageAttachments?.map((attachment, index) => { - const { width, height, filepath } = attachment; - return ( - - ); - })} + {imageAttachments?.map((attachment) => ( + + ))} ); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx index aa1b07827b..bdce6d3209 100644 --- a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/OpenAIImageGen.tsx @@ -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(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 ( <>
-
-
- {dimensions.width !== 'auto' && progress < 1 && ( - - )} +
+
+ {progress < 1 && }
diff --git a/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx new file mode 100644 index 0000000000..e7e0b99f1e --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/Image.test.tsx @@ -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) => ( +
+ ), +})); + +jest.mock('../DialogImage', () => ({ + __esModule: true, + default: ({ isOpen, src }: { isOpen: boolean; src: string }) => + isOpen ?
: 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(); + const img = screen.getByRole('img'); + expect(img.className).toContain('max-h-[45vh]'); + }); + + it('renders with max-w-full to prevent landscape clipping', () => { + render(); + 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(); + 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(); + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); + }); + + it('does not apply heightStyle without dimensions', () => { + render(); + const button = screen.getByRole('button'); + expect(button.style.height).toBeFalsy(); + }); + }); + + describe('rendering with dimensions', () => { + it('shows skeleton behind image', () => { + render(); + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + }); + + it('applies computed heightStyle to button', () => { + render(); + 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(); + const img = screen.getByRole('img'); + expect(img.className).toContain('size-full'); + expect(img.className).toContain('object-contain'); + }); + + it('skeleton is absolute inset-0', () => { + render(); + 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(); + const img = screen.getByRole('img'); + + expect(screen.getByTestId('skeleton')).toBeInTheDocument(); + + fireEvent.load(img); + + // Rerender same component — skeleton should not show (URL painted) + rerender(); + expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument(); + }); + }); + + describe('common behavior', () => { + it('applies custom className to the button wrapper', () => { + render(); + const button = screen.getByRole('button'); + expect(button.className).toContain('mb-4'); + }); + + it('sets correct alt text', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Test image'); + }); + + it('has correct accessibility attributes on button', () => { + render(); + 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(); + 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(); + // 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(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/images/test.png'); + }); + + it('passes absolute http URLs through unchanged', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg'); + }); + + it('passes data URIs through unchanged', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'data:image/png;base64,abc'); + }); + + it('passes non-/images/ paths through unchanged', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/other/path.png'); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx new file mode 100644 index 0000000000..ef8ac2807a --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/OpenAIImageGen.test.tsx @@ -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; + }) => ( +
+ ), +})); + +jest.mock('@librechat/client', () => ({ + PixelCard: ({ progress }: { progress: number }) => ( +
+ ), +})); + +jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({ + __esModule: true, + default: ({ progress, error }: { progress: number; error: boolean }) => ( +
+ ), +})); + +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(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + + it('hides Image with invisible absolute while progress < 1', () => { + render(); + 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( + , + ); + 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(); + expect(screen.getByTestId('pixel-card')).toBeInTheDocument(); + }); + + it('hides PixelCard when progress >= 1', () => { + render(); + expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument(); + }); + }); + + describe('layout classes', () => { + it('applies max-h-[45vh] to the outer container', () => { + const { container } = render(); + 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(); + 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( + , + ); + const overflowDiv = container.querySelector('[class*="overflow-hidden"]'); + expect(overflowDiv?.className).toContain('w-auto'); + }); + }); + + describe('args parsing', () => { + it('parses quality from args', () => { + render(); + expect(screen.getByTestId('progress-text')).toBeInTheDocument(); + }); + + it('handles invalid JSON args gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + render(); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + + it('handles object args', () => { + render( + , + ); + expect(screen.getByTestId('image-component')).toBeInTheDocument(); + }); + }); + + describe('cancellation', () => { + it('shows error state when output contains error', () => { + render( + , + ); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + + it('shows cancelled state when not submitting and incomplete', () => { + render(); + const progressText = screen.getByTestId('progress-text'); + expect(progressText).toHaveAttribute('data-error', 'true'); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 88162f3287..7aa73a54e6 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) { )}
-
+
{isLast && isSubmitting ? ( -
+
) : ( -
+
; + return
; }); PlaceholderRow.displayName = 'PlaceholderRow'; diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index 3256145bfb..fae0f286d3 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -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(); + +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 = () => ( -
- -
- ); - - return ( -
- {(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) || - imageError ? ( - renderDefaultAvatar() - ) : ( - avatar - )} -
- ); -}); + return ( +
+ {resolved.type === 'image' ? ( + avatar setResolved(markAvatarFailed(userId, resolved.src))} + /> + ) : ( +
+ +
+ )} +
+ ); + }, +); UserAvatar.displayName = 'UserAvatar'; @@ -74,9 +112,10 @@ const Icon: React.FC = memo((props) => { return ( ); diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index a50b91c071..4114baefe4 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -144,7 +144,7 @@ const ContentRender = memo(function ContentRender({ )}
-
+
{messageLabel}
-
+
; - 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 }; -}; diff --git a/client/src/hooks/Files/__tests__/useFileHandling.test.ts b/client/src/hooks/Files/__tests__/useFileHandling.test.ts index 297b0bd94d..0a07c5f2b4 100644 --- a/client/src/hooks/Files/__tests__/useFileHandling.test.ts +++ b/client/src/hooks/Files/__tests__/useFileHandling.test.ts @@ -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; + }; 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, }; diff --git a/client/src/hooks/Files/useFileDeletion.ts b/client/src/hooks/Files/useFileDeletion.ts index 34d89e313b..c33ac2a50b 100644 --- a/client/src/hooks/Files/useFileDeletion.ts +++ b/client/src/hooks/Files/useFileDeletion.ts @@ -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>; @@ -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) { diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 68d56e75ad..be62700651 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -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, diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 9f809bd6c1..325ee97315 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -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 ( diff --git a/client/src/mobile.css b/client/src/mobile.css index 20eeb5d1da..0d31b41134 100644 --- a/client/src/mobile.css +++ b/client/src/mobile.css @@ -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; } diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index a1caf8c07e..13f3a23b47 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -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, diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 8946951ed8..dae075b471 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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'; diff --git a/client/src/utils/previewCache.ts b/client/src/utils/previewCache.ts new file mode 100644 index 0000000000..604ce56308 --- /dev/null +++ b/client/src/utils/previewCache.ts @@ -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(); + +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(); +} diff --git a/client/src/utils/scaleImage.ts b/client/src/utils/scaleImage.ts deleted file mode 100644 index 11e051fbd9..0000000000 --- a/client/src/utils/scaleImage.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default function scaleImage({ - originalWidth, - originalHeight, - containerRef, -}: { - originalWidth?: number; - originalHeight?: number; - containerRef: React.RefObject; -}) { - 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` }; -} diff --git a/package-lock.json b/package-lock.json index 6be9adfa61..53e0b2e642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",