diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index e0a381ff52..11fc3cafa5 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo, useRef, useEffect } from 'react'; +import React, { memo, useMemo, useRef, useEffect, lazy, Suspense } from 'react'; import { useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { useToastContext, useCodeBlockContext } from '~/Providers'; @@ -9,6 +9,16 @@ import useLocalize from '~/hooks/useLocalize'; import { handleDoubleClick } from '~/utils'; import store from '~/store'; +// Loading fallback component for lazy-loaded Mermaid diagrams +const MermaidLoadingFallback = memo(() => { + const localize = useLocalize(); + return ( +
+ {localize('com_ui_loading_diagram')} +
+ ); +}); + type TCodeProps = { inline?: boolean; className?: string; @@ -23,6 +33,7 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps const match = /language-(\w+)/.exec(className ?? ''); const lang = match && match[1]; const isMath = lang === 'math'; + const isMermaid = lang === 'mermaid'; const isSingleLine = typeof children === 'string' && children.split('\n').length === 1; const { getNextIndex, resetCounter } = useCodeBlockContext(); @@ -34,6 +45,13 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps if (isMath) { return <>{children}; + } else if (isMermaid && typeof children === 'string') { + const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram')); + return ( + }> + + + ); } else if (isSingleLine) { return ( @@ -55,9 +73,17 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => { const match = /language-(\w+)/.exec(className ?? ''); const lang = match && match[1]; + const isMermaid = lang === 'mermaid'; if (lang === 'math') { return children; + } else if (isMermaid && typeof children === 'string') { + const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram')); + return ( + }> + + + ); } else if (typeof children === 'string' && children.split('\n').length === 1) { return ( diff --git a/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx b/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx new file mode 100644 index 0000000000..66d879e260 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx @@ -0,0 +1,289 @@ +import React, { memo, useMemo, useEffect } from 'react'; +import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled'; +import dedent from 'dedent'; +import { cn } from '~/utils'; +import { sharedOptions } from '~/utils/artifacts'; + +interface SandpackMermaidDiagramProps { + content: string; + className?: string; +} + +// Minimal dependencies for Mermaid only +const mermaidDependencies = { + mermaid: '^11.8.1', + 'react-zoom-pan-pinch': '^3.7.0', +}; + +// Lean mermaid template with inline SVG icons +const leanMermaidTemplate = dedent` +import React, { useEffect, useRef, useState } from "react"; +import { + TransformWrapper, + TransformComponent, + ReactZoomPanPinchRef, +} from "react-zoom-pan-pinch"; +import mermaid from "mermaid"; + +// Inline SVG icons +const ZoomInIcon = () => ( + + + + + + +); + +const ZoomOutIcon = () => ( + + + + + +); + +const ResetIcon = () => ( + + + + + +); + +interface MermaidDiagramProps { + content: string; +} + +const MermaidDiagram: React.FC = ({ content }) => { + const mermaidRef = useRef(null); + const transformRef = useRef(null); + const [isRendered, setIsRendered] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + mermaid.initialize({ + startOnLoad: false, + theme: "default", + securityLevel: "loose", + flowchart: { + useMaxWidth: true, + htmlLabels: true, + curve: "basis", + }, + }); + + const renderDiagram = async () => { + if (mermaidRef.current) { + try { + const id = "mermaid-" + Date.now(); + const { svg } = await mermaid.render(id, content); + mermaidRef.current.innerHTML = svg; + + const svgElement = mermaidRef.current.querySelector("svg"); + if (svgElement) { + svgElement.style.width = "100%"; + svgElement.style.height = "100%"; + } + setIsRendered(true); + setError(null); + } catch (err) { + console.error("Mermaid rendering error:", err); + setError(err.message || "Failed to render diagram"); + } + } + }; + + renderDiagram(); + }, [content]); + + const handleZoomIn = () => { + if (transformRef.current) { + transformRef.current.zoomIn(0.2); + } + }; + + const handleZoomOut = () => { + if (transformRef.current) { + transformRef.current.zoomOut(0.2); + } + }; + + const handleReset = () => { + if (transformRef.current) { + transformRef.current.resetTransform(); + transformRef.current.centerView(1, 0); + } + }; + + if (error) { + return ( +
+ Error: {error} +
+ ); + } + + return ( +
+ + +
+ + + + {isRendered && ( +
+ + + +
+ )} +
+ ); +}; + +export default MermaidDiagram; +`; + +const wrapLeanMermaidDiagram = (content: string) => { + return dedent` +import React from 'react'; +import MermaidDiagram from './MermaidDiagram'; + +export default function App() { + const content = \`${content.replace(/`/g, '\\`')}\`; + return ; +} +`; +}; + +const getLeanMermaidFiles = (content: string) => { + return { + '/App.tsx': wrapLeanMermaidDiagram(content), + '/MermaidDiagram.tsx': leanMermaidTemplate, + }; +}; + +const SandpackMermaidDiagram = memo(({ content, className }: SandpackMermaidDiagramProps) => { + const files = useMemo(() => getLeanMermaidFiles(content), [content]); + const sandpackProps = useMemo( + () => ({ + customSetup: { + dependencies: mermaidDependencies, + }, + }), + [], + ); + + // Force iframe to respect container height + useEffect(() => { + const fixIframeHeight = () => { + const container = document.querySelector('.sandpack-mermaid-diagram'); + if (container) { + const iframe = container.querySelector('iframe'); + if (iframe && iframe.style.height && iframe.style.height !== '100%') { + iframe.style.height = '100%'; + iframe.style.minHeight = '100%'; + } + } + }; + + // Initial fix + fixIframeHeight(); + + // Fix on any DOM changes + const observer = new MutationObserver(fixIframeHeight); + const container = document.querySelector('.sandpack-mermaid-diagram'); + if (container) { + observer.observe(container, { + attributes: true, + childList: true, + subtree: true, + attributeFilter: ['style'], + }); + } + + return () => observer.disconnect(); + }, [content]); + + return ( + + + + ); +}); + +SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram'; + +export default SandpackMermaidDiagram; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 655a5a825a..5d12b39177 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -857,6 +857,7 @@ "com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.", "com_ui_librechat_code_api_title": "Run AI Code", "com_ui_loading": "Loading...", + "com_ui_loading_diagram": "Loading diagram...", "com_ui_locked": "Locked", "com_ui_logo": "{{0}} Logo", "com_ui_low": "Low", diff --git a/client/src/mobile.css b/client/src/mobile.css index 890053e221..a09466fcbb 100644 --- a/client/src/mobile.css +++ b/client/src/mobile.css @@ -371,4 +371,130 @@ p.whitespace-pre-wrap a, li a { .dark p.whitespace-pre-wrap a, .dark li a { color: #52a0ff; -} \ No newline at end of file +} + +/* .sandpack-mermaid-diagram { + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram > div { + height: 100% !important; + min-height: 100% !important; + flex: 1 !important; +} + +.sandpack-mermaid-diagram .sp-wrapper { + height: 100% !important; + min-height: inherit !important; + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram .sp-stack { + height: 100% !important; + min-height: inherit !important; + flex: 1 !important; + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram .sp-preview { + height: 100% !important; + min-height: inherit !important; + flex: 1 !important; + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram .sp-preview-container { + height: 100% !important; + min-height: inherit !important; + flex: 1 !important; + background: transparent !important; + display: flex !important; + flex-direction: column !important; + position: relative !important; +} + +.sandpack-mermaid-diagram .sp-preview-iframe { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + min-height: 100% !important; + border: none !important; +} + +.sandpack-mermaid-diagram .sp-preview-actions { + display: none !important; +} + +.sandpack-mermaid-diagram .sp-preview-container::after { + display: none !important; +} + +.sandpack-mermaid-diagram [style*="height: 346px"] { + height: 100% !important; +} + +.sandpack-mermaid-diagram iframe[style*="height"] { + height: 100% !important; +} + +.sandpack-mermaid-diagram [style*="height:"] { + height: 100% !important; + min-height: 100% !important; +} + +.sandpack-mermaid-diagram iframe { + height: 100% !important; + min-height: 100% !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; +} + +.sandpack-mermaid-diagram .sp-stack { + max-height: none !important; +} + +.sandpack-mermaid-diagram .sp-wrapper, +.sandpack-mermaid-diagram .sp-stack, +.sandpack-mermaid-diagram .sp-preview, +.sandpack-mermaid-diagram .sp-preview-container { + max-height: none !important; + height: 100% !important; + min-height: 100% !important; +} + +.sandpack-mermaid-diagram .p-4 > div { + height: 100% !important; + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram .sp-wrapper { + height: 100% !important; + display: flex !important; + flex-direction: column !important; +} + +.sandpack-mermaid-diagram iframe[style*="height"] { + height: 100% !important; +} + +.sandpack-mermaid-diagram [style*="height:"] { + height: 100% !important; +} + +.sandpack-mermaid-diagram .sp-wrapper, +.sandpack-mermaid-diagram .sp-stack, +.sandpack-mermaid-diagram .sp-preview, +.sandpack-mermaid-diagram .sp-preview-container { + max-height: none !important; +} */ \ No newline at end of file diff --git a/client/vite.config.ts b/client/vite.config.ts index 4c4c5a7d65..9f5dfdc83e 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -113,8 +113,8 @@ export default defineConfig(({ command }) => ({ if (id.includes('i18next') || id.includes('react-i18next')) { return 'i18n'; } - if (id.includes('lodash')) { - return 'utilities'; + if (id.includes('node_modules/lodash-es')) { + return 'lodash-es'; } if (id.includes('date-fns')) { return 'date-utils';