feat: Add lazy-loaded Mermaid diagram support with loading fallback and zoom features

This commit is contained in:
Danny Avila 2025-07-12 12:38:33 -04:00
parent 4136dda7c7
commit f67dd1b1b7
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
5 changed files with 446 additions and 4 deletions

View file

@ -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 (
<div className="my-4 rounded-lg border border-border-light bg-surface-primary p-4 text-center text-text-secondary dark:border-border-heavy dark:bg-surface-primary-alt">
{localize('com_ui_loading_diagram')}
</div>
);
});
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 (
<Suspense fallback={<MermaidLoadingFallback />}>
<SandpackMermaidDiagram content={children} />
</Suspense>
);
} else if (isSingleLine) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>
@ -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 (
<Suspense fallback={<MermaidLoadingFallback />}>
<SandpackMermaidDiagram content={children} />
</Suspense>
);
} else if (typeof children === 'string' && children.split('\n').length === 1) {
return (
<code onDoubleClick={handleDoubleClick} className={className}>

View file

@ -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 = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
<line x1="11" y1="8" x2="11" y2="14"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
);
const ZoomOutIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
);
const ResetIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="1 4 1 10 7 10"/>
<polyline points="23 20 23 14 17 14"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
);
interface MermaidDiagramProps {
content: string;
}
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
const mermaidRef = useRef<HTMLDivElement>(null);
const transformRef = useRef<ReactZoomPanPinchRef>(null);
const [isRendered, setIsRendered] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div style={{ padding: '16px', color: '#ef4444', backgroundColor: '#fee2e2', borderRadius: '8px', border: '1px solid #fecaca' }}>
<strong>Error:</strong> {error}
</div>
);
}
return (
<div style={{ position: 'relative', height: '100%', width: '100%', backgroundColor: '#f9fafb' }}>
<TransformWrapper
ref={transformRef}
initialScale={1}
minScale={0.1}
maxScale={4}
wheel={{ step: 0.1 }}
centerOnInit={true}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
}}
>
<div
ref={mermaidRef}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '300px',
padding: '20px',
}}
/>
</TransformComponent>
</TransformWrapper>
{isRendered && (
<div style={{ position: 'absolute', bottom: '8px', right: '8px', display: 'flex', gap: '8px' }}>
<button
onClick={handleZoomIn}
style={{
padding: '8px',
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Zoom in"
>
<ZoomInIcon />
</button>
<button
onClick={handleZoomOut}
style={{
padding: '8px',
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Zoom out"
>
<ZoomOutIcon />
</button>
<button
onClick={handleReset}
style={{
padding: '8px',
backgroundColor: 'white',
border: '1px solid #e5e7eb',
borderRadius: '6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
title="Reset zoom"
>
<ResetIcon />
</button>
</div>
)}
</div>
);
};
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 <MermaidDiagram content={content} />;
}
`;
};
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 (
<SandpackProvider files={files} options={sharedOptions} template="react-ts" {...sandpackProps}>
<SandpackPreview
showOpenInCodeSandbox={false}
showRefreshButton={false}
showSandpackErrorOverlay={true}
/>
</SandpackProvider>
);
});
SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram';
export default SandpackMermaidDiagram;

View file

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

View file

@ -371,4 +371,130 @@ p.whitespace-pre-wrap a, li a {
.dark p.whitespace-pre-wrap a, .dark li a {
color: #52a0ff;
}
}
/* .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;
} */

View file

@ -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';