mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
feat: Add lazy-loaded Mermaid diagram support with loading fallback and zoom features
This commit is contained in:
parent
4136dda7c7
commit
f67dd1b1b7
5 changed files with 446 additions and 4 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} */
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue