@@ -59,6 +64,9 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
if (lang === 'math') {
return children;
+ } else if (lang === 'mermaid') {
+ const content = typeof children === 'string' ? children : String(children);
+ return {content} ;
} else if (typeof children === 'string' && children.split('\n').length === 1) {
return (
diff --git a/client/src/components/Messages/Content/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid.tsx
new file mode 100644
index 0000000000..02a3086c3c
--- /dev/null
+++ b/client/src/components/Messages/Content/Mermaid.tsx
@@ -0,0 +1,684 @@
+import React, { useEffect, useMemo, useState, useRef, useCallback, memo } from 'react';
+import copy from 'copy-to-clipboard';
+import {
+ ZoomIn,
+ Expand,
+ ZoomOut,
+ ChevronUp,
+ RefreshCw,
+ RotateCcw,
+ ChevronDown,
+} from 'lucide-react';
+import {
+ Button,
+ Spinner,
+ OGDialog,
+ Clipboard,
+ CheckMark,
+ OGDialogTitle,
+ OGDialogContent,
+} from '@librechat/client';
+import { useLocalize, useDebouncedMermaid } from '~/hooks';
+import cn from '~/utils/cn';
+
+interface MermaidProps {
+ /** Mermaid diagram content */
+ children: string;
+ /** Unique identifier */
+ id?: string;
+ /** Custom theme */
+ theme?: string;
+}
+
+const MIN_ZOOM = 0.25;
+const MAX_ZOOM = 3;
+const ZOOM_STEP = 0.25;
+
+const Mermaid: React.FC = memo(({ children, id, theme }) => {
+ const localize = useLocalize();
+ const [blobUrl, setBlobUrl] = useState('');
+ const [isCopied, setIsCopied] = useState(false);
+ const [showCode, setShowCode] = useState(false);
+ const [retryCount, setRetryCount] = useState(0);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ // Separate showCode state for dialog to avoid re-renders
+ const [dialogShowCode, setDialogShowCode] = useState(false);
+ const lastValidSvgRef = useRef(null);
+ const expandButtonRef = useRef(null);
+ const showCodeButtonRef = useRef(null);
+ const copyButtonRef = useRef(null);
+ const dialogShowCodeButtonRef = useRef(null);
+ const dialogCopyButtonRef = useRef(null);
+
+ // Zoom and pan state
+ const [zoom, setZoom] = useState(1);
+ // Dialog zoom and pan state (separate from inline view)
+ const [dialogZoom, setDialogZoom] = useState(1);
+ const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 });
+ const [isDialogPanning, setIsDialogPanning] = useState(false);
+ const dialogPanStartRef = useRef({ x: 0, y: 0 });
+ const [pan, setPan] = useState({ x: 0, y: 0 });
+ const [isPanning, setIsPanning] = useState(false);
+ const panStartRef = useRef({ x: 0, y: 0 });
+ const containerRef = useRef(null);
+ const streamingCodeRef = useRef(null);
+
+ // Get SVG from debounced hook (handles streaming gracefully)
+ const { svg, isLoading, error } = useDebouncedMermaid({
+ content: children,
+ id,
+ theme,
+ key: retryCount,
+ });
+
+ // Auto-scroll streaming code to bottom
+ useEffect(() => {
+ if (isLoading && streamingCodeRef.current) {
+ streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight;
+ }
+ }, [children, isLoading]);
+
+ // Store last valid SVG for showing during updates
+ useEffect(() => {
+ if (svg) {
+ lastValidSvgRef.current = svg;
+ }
+ }, [svg]);
+
+ // Process SVG and create blob URL
+ const processedSvg = useMemo(() => {
+ if (!svg) {
+ return null;
+ }
+
+ let finalSvg = svg;
+
+ // Firefox fix: Ensure viewBox is set correctly
+ if (!svg.includes('viewBox') && svg.includes('height=') && svg.includes('width=')) {
+ const widthMatch = svg.match(/width="(\d+)"/);
+ const heightMatch = svg.match(/height="(\d+)"/);
+
+ if (widthMatch && heightMatch) {
+ const width = widthMatch[1];
+ const height = heightMatch[1];
+ finalSvg = svg.replace('