mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-09 12:08:50 +01:00
📊 feat: Render Inline Mermaid Diagrams (#11112)
* chore: add mermaid, swr, ts-md5 packages * WIP: first pass, inline mermaid * feat: Enhance Mermaid component with zoom, pan, and error handling features * feat: Update Mermaid component styles for improved UI consistency * feat: Improve Mermaid rendering with enhanced debouncing and error handling * refactor: Update Mermaid component styles and enhance error handling in useMermaid hook * feat: Enhance security settings in useMermaid configuration to prevent DoS attacks * feat: Add dialog for expanded Mermaid view with zoom and pan controls * feat: Implement auto-scroll for streaming code in Mermaid component * feat: Replace loading spinner with reusable Spinner component in Mermaid * feat: Sanitize SVG output in useMermaid to enhance security * feat: Enhance SVG sanitization in useMermaid to support additional elements for text rendering * refactor: Enhance initial content check in useDebouncedMermaid for improved rendering logic * feat: Refactor Mermaid component to use Button component and enhance focus management for code toggling and copying * chore: remove unused key * refactor: initial content check in useDebouncedMermaid to detect significant content changes
This commit is contained in:
parent
43c2c20dd7
commit
3503b7caeb
10 changed files with 2321 additions and 9 deletions
|
|
@ -80,6 +80,7 @@
|
|||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^8.1.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"rc-input-number": "^7.4.2",
|
||||
|
|
@ -109,9 +110,11 @@
|
|||
"remark-math": "^6.0.0",
|
||||
"remark-supersub": "^1.0.0",
|
||||
"sse.js": "^2.5.0",
|
||||
"swr": "^2.3.8",
|
||||
"tailwind-merge": "^1.9.1",
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"tailwindcss-radix": "^2.8.0",
|
||||
"ts-md5": "^1.3.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import { useToastContext } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import Mermaid from '~/components/Messages/Content/Mermaid';
|
||||
import useHasAccess from '~/hooks/Roles/useHasAccess';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import { useCodeBlockContext } from '~/Providers';
|
||||
|
|
@ -24,10 +25,11 @@ 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();
|
||||
const blockIndex = useRef(getNextIndex(isMath || isSingleLine)).current;
|
||||
const blockIndex = useRef(getNextIndex(isMath || isMermaid || isSingleLine)).current;
|
||||
|
||||
useEffect(() => {
|
||||
resetCounter();
|
||||
|
|
@ -35,6 +37,9 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
|||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isMermaid) {
|
||||
const content = typeof children === 'string' ? children : String(children);
|
||||
return <Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>;
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
@ -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 <Mermaid>{content}</Mermaid>;
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
|
|||
684
client/src/components/Messages/Content/Mermaid.tsx
Normal file
684
client/src/components/Messages/Content/Mermaid.tsx
Normal file
|
|
@ -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<MermaidProps> = memo(({ children, id, theme }) => {
|
||||
const localize = useLocalize();
|
||||
const [blobUrl, setBlobUrl] = useState<string>('');
|
||||
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<string | null>(null);
|
||||
const expandButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogCopyButtonRef = useRef<HTMLButtonElement>(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<HTMLDivElement>(null);
|
||||
const streamingCodeRef = useRef<HTMLPreElement>(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('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure SVG has proper XML namespace
|
||||
if (!finalSvg.includes('xmlns')) {
|
||||
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
|
||||
return finalSvg;
|
||||
}, [svg]);
|
||||
|
||||
// Create blob URL for the SVG
|
||||
useEffect(() => {
|
||||
if (!processedSvg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([processedSvg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
|
||||
return () => {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
}, [processedSvg]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
setIsCopied(true);
|
||||
requestAnimationFrame(() => {
|
||||
copyButtonRef.current?.focus();
|
||||
});
|
||||
setTimeout(() => {
|
||||
// Save currently focused element before state update causes re-render
|
||||
const focusedElement = document.activeElement as HTMLElement | null;
|
||||
setIsCopied(false);
|
||||
// Restore focus to whatever was focused (React re-render may have disrupted it)
|
||||
requestAnimationFrame(() => {
|
||||
focusedElement?.focus();
|
||||
});
|
||||
}, 3000);
|
||||
}, [children]);
|
||||
|
||||
const handleDialogCopy = useCallback(() => {
|
||||
copy(children.trim(), { format: 'text/plain' });
|
||||
requestAnimationFrame(() => {
|
||||
dialogCopyButtonRef.current?.focus();
|
||||
});
|
||||
}, [children]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
// Toggle code with focus restoration
|
||||
const handleToggleCode = useCallback(() => {
|
||||
setShowCode((prev) => !prev);
|
||||
requestAnimationFrame(() => {
|
||||
showCodeButtonRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Toggle dialog code with focus restoration
|
||||
const handleToggleDialogCode = useCallback(() => {
|
||||
setDialogShowCode((prev) => !prev);
|
||||
requestAnimationFrame(() => {
|
||||
dialogShowCodeButtonRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Zoom handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// Dialog zoom handlers
|
||||
const handleDialogZoomIn = useCallback(() => {
|
||||
setDialogZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleDialogZoomOut = useCallback(() => {
|
||||
setDialogZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
|
||||
}, []);
|
||||
|
||||
const handleDialogResetZoom = useCallback(() => {
|
||||
setDialogZoom(1);
|
||||
setDialogPan({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const handleDialogWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
setDialogZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDialogMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isButton = target.tagName === 'BUTTON' || target.closest('button');
|
||||
if (e.button === 0 && !isButton) {
|
||||
setIsDialogPanning(true);
|
||||
dialogPanStartRef.current = { x: e.clientX - dialogPan.x, y: e.clientY - dialogPan.y };
|
||||
}
|
||||
},
|
||||
[dialogPan],
|
||||
);
|
||||
|
||||
const handleDialogMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isDialogPanning) {
|
||||
setDialogPan({
|
||||
x: e.clientX - dialogPanStartRef.current.x,
|
||||
y: e.clientY - dialogPanStartRef.current.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDialogPanning],
|
||||
);
|
||||
|
||||
const handleDialogMouseUp = useCallback(() => {
|
||||
setIsDialogPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleDialogMouseLeave = useCallback(() => {
|
||||
setIsDialogPanning(false);
|
||||
}, []);
|
||||
|
||||
// Mouse wheel zoom
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Pan handlers
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only start panning on left click and not on buttons/icons inside buttons
|
||||
const target = e.target as HTMLElement;
|
||||
const isButton = target.tagName === 'BUTTON' || target.closest('button');
|
||||
if (e.button === 0 && !isButton) {
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
|
||||
}
|
||||
},
|
||||
[pan],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
setPan({
|
||||
x: e.clientX - panStartRef.current.x,
|
||||
y: e.clientY - panStartRef.current.y,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPanning],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// Header component (shared across states)
|
||||
const Header = ({
|
||||
showActions = false,
|
||||
showExpandButton = false,
|
||||
}: {
|
||||
showActions?: boolean;
|
||||
showExpandButton?: boolean;
|
||||
}) => (
|
||||
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
{showActions && (
|
||||
<div className="ml-auto flex gap-2">
|
||||
{showExpandButton && (
|
||||
<Button
|
||||
ref={expandButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={() => {
|
||||
setDialogShowCode(false);
|
||||
setDialogZoom(1);
|
||||
setDialogPan({ x: 0, y: 0 });
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_expand')}
|
||||
>
|
||||
<Expand className="h-4 w-4" />
|
||||
{localize('com_ui_expand')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ref={showCodeButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleToggleCode}
|
||||
>
|
||||
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={copyButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark className="h-[18px] w-[18px]" />
|
||||
{localize('com_ui_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
{localize('com_ui_copy_code')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Zoom controls - inline JSX to avoid stale closure issues
|
||||
const zoomControls = (
|
||||
<div className="absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomOut();
|
||||
}}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomIn();
|
||||
}}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetZoom();
|
||||
}}
|
||||
disabled={zoom === 1 && pan.x === 0 && pan.y === 0}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Dialog zoom controls
|
||||
const dialogZoomControls = (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomOut();
|
||||
}}
|
||||
disabled={dialogZoom <= MIN_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-[3rem] text-center text-xs text-text-secondary">
|
||||
{Math.round(dialogZoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomIn();
|
||||
}}
|
||||
disabled={dialogZoom >= MAX_ZOOM}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogResetZoom();
|
||||
}}
|
||||
disabled={dialogZoom === 1 && dialogPan.x === 0 && dialogPan.y === 0}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
title={localize('com_ui_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Full-screen dialog - rendered inline, not as function component to avoid recreation
|
||||
const expandedDialog = (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} triggerRef={expandButtonRef}>
|
||||
<OGDialogContent className="h-[85vh] max-h-[85vh] w-[90vw] max-w-[90vw] border-border-light bg-surface-primary p-0">
|
||||
<OGDialogTitle className="flex items-center justify-between rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
ref={dialogShowCodeButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleToggleDialogCode}
|
||||
>
|
||||
{dialogShowCode ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
{dialogShowCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={dialogCopyButtonRef}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
|
||||
onClick={handleDialogCopy}
|
||||
>
|
||||
<Clipboard />
|
||||
{localize('com_ui_copy_code')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogTitle>
|
||||
{dialogShowCode && (
|
||||
<div className="border-b border-border-medium bg-surface-secondary p-4">
|
||||
<pre className="max-h-[150px] overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex-1 overflow-hidden p-4',
|
||||
'bg-surface-primary-alt',
|
||||
isDialogPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ height: dialogShowCode ? 'calc(85vh - 200px)' : 'calc(85vh - 50px)' }}
|
||||
onWheel={handleDialogWheel}
|
||||
onMouseDown={handleDialogMouseDown}
|
||||
onMouseMove={handleDialogMouseMove}
|
||||
onMouseUp={handleDialogMouseUp}
|
||||
onMouseLeave={handleDialogMouseLeave}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${dialogPan.x}px, ${dialogPan.y}px)`,
|
||||
transition: isDialogPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-h-full max-w-full select-none object-contain"
|
||||
style={{
|
||||
transform: `scale(${dialogZoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{dialogZoomControls}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
|
||||
// Loading state - show last valid diagram with loading indicator, or spinner
|
||||
if (isLoading) {
|
||||
// If we have a previous valid render, show it with a subtle loading indicator
|
||||
if (lastValidSvgRef.current && blobUrl) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden p-4',
|
||||
'rounded-b-md bg-surface-primary-alt',
|
||||
isPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ minHeight: '250px', maxHeight: '600px' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
|
||||
<Spinner className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-w-full select-none opacity-70"
|
||||
style={{
|
||||
maxHeight: '500px',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{zoomControls}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No previous render, show streaming code
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<div className="flex items-center gap-2 rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
<Spinner className="h-3 w-3 text-gray-200" />
|
||||
<span>{localize('com_ui_mermaid')}</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={streamingCodeRef}
|
||||
className="max-h-[350px] min-h-[150px] overflow-auto whitespace-pre-wrap rounded-b-md bg-surface-primary-alt p-4 font-mono text-xs text-text-secondary"
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions />
|
||||
<div className="rounded-b-md border-t border-red-500/30 bg-red-500/10 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-semibold text-red-500 dark:text-red-400">
|
||||
{localize('com_ui_mermaid_failed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{localize('com_ui_retry')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-auto text-xs text-red-600 dark:text-red-300">
|
||||
{error.message}
|
||||
</pre>
|
||||
{showCode && (
|
||||
<div className="mt-4 border-t border-border-medium pt-4">
|
||||
<div className="mb-2 text-xs text-text-secondary">
|
||||
{localize('com_ui_mermaid_source')}
|
||||
</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (!blobUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{expandedDialog}
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<Header showActions showExpandButton />
|
||||
{showCode && (
|
||||
<div className="border-b border-border-medium bg-surface-secondary p-4">
|
||||
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden p-4',
|
||||
'bg-surface-primary-alt',
|
||||
!showCode && 'rounded-b-md',
|
||||
isPanning ? 'cursor-grabbing' : 'cursor-grab',
|
||||
)}
|
||||
style={{ minHeight: '250px', maxHeight: '600px' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-[200px] items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt="Mermaid diagram"
|
||||
className="max-w-full select-none"
|
||||
style={{
|
||||
maxHeight: '500px',
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{zoomControls}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Mermaid.displayName = 'Mermaid';
|
||||
|
||||
export default Mermaid;
|
||||
2
client/src/hooks/Mermaid/index.ts
Normal file
2
client/src/hooks/Mermaid/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useMermaid, default } from './useMermaid';
|
||||
export { useDebouncedMermaid } from './useDebouncedMermaid';
|
||||
204
client/src/hooks/Mermaid/useDebouncedMermaid.ts
Normal file
204
client/src/hooks/Mermaid/useDebouncedMermaid.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useMermaid } from './useMermaid';
|
||||
|
||||
/**
|
||||
* Detect if mermaid content is likely incomplete (still streaming)
|
||||
*/
|
||||
const isLikelyStreaming = (content: string): boolean => {
|
||||
if (content.length < 15) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const incompletePatterns = [
|
||||
/\[\s*$/, // Ends with opening bracket: "A["
|
||||
/--+$/, // Ends with arrows: "A--"
|
||||
/>>+$/, // Ends with sequence arrow: "A>>"
|
||||
/-\|$/, // Ends with arrow: "A-|"
|
||||
/\|\s*$/, // Ends with pipe: "A|"
|
||||
/^\s*graph\s+[A-Z]*$/, // Just "graph TD" or "graph"
|
||||
/^\s*sequenceDiagram\s*$/, // Just "sequenceDiagram"
|
||||
/^\s*flowchart\s+[A-Z]*$/, // Just "flowchart TD"
|
||||
/^\s*classDiagram\s*$/, // Just "classDiagram"
|
||||
/^\s*stateDiagram\s*$/, // Just "stateDiagram"
|
||||
/^\s*erDiagram\s*$/, // Just "erDiagram"
|
||||
/^\s*gantt\s*$/, // Just "gantt"
|
||||
/^\s*pie\s*$/, // Just "pie"
|
||||
/:\s*$/, // Ends with colon (incomplete label)
|
||||
/"\s*$/, // Ends with unclosed quote
|
||||
];
|
||||
|
||||
return incompletePatterns.some((pattern) => pattern.test(content));
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if content looks complete (has closing structure)
|
||||
*/
|
||||
const looksComplete = (content: string): boolean => {
|
||||
const lines = content.trim().split('\n');
|
||||
if (lines.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Has complete node connections (flowchart/graph)
|
||||
const hasConnections =
|
||||
/[A-Za-z]\w*(\[.*?\]|\(.*?\)|\{.*?\})?(\s*--+>?\s*|\s*-+\.\s*|\s*==+>?\s*)[A-Za-z]\w*/.test(
|
||||
content,
|
||||
);
|
||||
|
||||
// Has sequence diagram messages
|
||||
const hasSequenceMessages = /\w+-+>>?\+?\w+:/.test(content);
|
||||
|
||||
// Has class diagram relations
|
||||
const hasClassRelations = /\w+\s*(<\|--|--|\.\.>|--\*|--o)\s*\w+/.test(content);
|
||||
|
||||
// Has state transitions
|
||||
const hasStateTransitions = /\[\*\]\s*-->|\w+\s*-->\s*\w+/.test(content);
|
||||
|
||||
// Has ER diagram relations
|
||||
const hasERRelations = /\w+\s*\|\|--o\{|\w+\s*}o--\|\|/.test(content);
|
||||
|
||||
// Has gantt tasks
|
||||
const hasGanttTasks = /^\s*\w+\s*:\s*\w+/.test(content);
|
||||
|
||||
return (
|
||||
hasConnections ||
|
||||
hasSequenceMessages ||
|
||||
hasClassRelations ||
|
||||
hasStateTransitions ||
|
||||
hasERRelations ||
|
||||
hasGanttTasks
|
||||
);
|
||||
};
|
||||
|
||||
interface UseDebouncedMermaidOptions {
|
||||
/** Mermaid diagram content */
|
||||
content: string;
|
||||
/** Unique identifier */
|
||||
id?: string;
|
||||
/** Custom theme */
|
||||
theme?: string;
|
||||
/** Delay before attempting render (ms) */
|
||||
delay?: number;
|
||||
/** Minimum content length before attempting render */
|
||||
minLength?: number;
|
||||
/** Key to force re-render (e.g., for retry functionality) */
|
||||
key?: number;
|
||||
}
|
||||
|
||||
export const useDebouncedMermaid = ({
|
||||
content,
|
||||
id,
|
||||
theme,
|
||||
delay = 500,
|
||||
minLength = 15,
|
||||
key = 0,
|
||||
}: UseDebouncedMermaidOptions) => {
|
||||
// Check if content looks complete on initial mount or when content changes significantly
|
||||
// Using refs to capture state and detect significant content changes (e.g., user edits message)
|
||||
const initialCheckRef = useRef<boolean | null>(null);
|
||||
const contentLengthRef = useRef(content.length);
|
||||
|
||||
// Reset check if content length changed significantly (more than 20% difference)
|
||||
const lengthDiff = Math.abs(content.length - contentLengthRef.current);
|
||||
const significantChange = lengthDiff > contentLengthRef.current * 0.2 && lengthDiff > 50;
|
||||
|
||||
if (initialCheckRef.current === null || significantChange) {
|
||||
contentLengthRef.current = content.length;
|
||||
initialCheckRef.current =
|
||||
content.length >= minLength && looksComplete(content) && !isLikelyStreaming(content);
|
||||
}
|
||||
const isInitiallyComplete = initialCheckRef.current;
|
||||
|
||||
const [debouncedContent, setDebouncedContent] = useState(content);
|
||||
const [shouldRender, setShouldRender] = useState(isInitiallyComplete);
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
const [forceRender, setForceRender] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const prevKeyRef = useRef(key);
|
||||
const hasRenderedRef = useRef(isInitiallyComplete);
|
||||
|
||||
// When key changes (retry), force immediate render
|
||||
useEffect(() => {
|
||||
if (key !== prevKeyRef.current) {
|
||||
prevKeyRef.current = key;
|
||||
setForceRender(true);
|
||||
setDebouncedContent(content);
|
||||
setShouldRender(true);
|
||||
setErrorCount(0);
|
||||
}
|
||||
}, [key, content]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip debounce logic if force render is active or already rendered initially
|
||||
if (forceRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already rendered on mount, skip the initial debounce
|
||||
if (hasRenderedRef.current && shouldRender) {
|
||||
// Content changed after initial render, apply normal debounce for updates
|
||||
if (content !== debouncedContent) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
const effectiveDelay = looksComplete(content) ? delay / 2 : delay;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedContent(content);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Don't render if too short or obviously incomplete
|
||||
if (content.length < minLength || (isLikelyStreaming(content) && !looksComplete(content))) {
|
||||
setShouldRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use shorter delay if content looks complete
|
||||
const effectiveDelay = looksComplete(content) ? delay / 2 : delay;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedContent(content);
|
||||
setShouldRender(true);
|
||||
hasRenderedRef.current = true;
|
||||
}, effectiveDelay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [content, delay, minLength, forceRender, shouldRender, debouncedContent]);
|
||||
|
||||
const result = useMermaid({
|
||||
content: shouldRender ? debouncedContent : '',
|
||||
id: id ? `${id}-${key}` : undefined,
|
||||
theme,
|
||||
});
|
||||
|
||||
// Track error count
|
||||
useEffect(() => {
|
||||
if (result.error) {
|
||||
setErrorCount((prev) => prev + 1);
|
||||
} else if (result.svg) {
|
||||
setErrorCount(0);
|
||||
setForceRender(false);
|
||||
}
|
||||
}, [result.error, result.svg]);
|
||||
|
||||
// Show error after multiple failures OR if forced render (retry) with error
|
||||
const shouldShowError = shouldRender && result.error && (errorCount > 2 || forceRender);
|
||||
|
||||
return {
|
||||
...result,
|
||||
isLoading: result.isLoading || !shouldRender,
|
||||
error: shouldShowError ? result.error : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDebouncedMermaid;
|
||||
182
client/src/hooks/Mermaid/useMermaid.ts
Normal file
182
client/src/hooks/Mermaid/useMermaid.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useContext, useMemo, useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import useSWR from 'swr';
|
||||
import { Md5 } from 'ts-md5';
|
||||
import { ThemeContext, isDark } from '@librechat/client';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
|
||||
// Constants
|
||||
const MD5_LENGTH_THRESHOLD = 10_000;
|
||||
const DEFAULT_ID_PREFIX = 'mermaid-diagram';
|
||||
|
||||
// Lazy load mermaid library (~2MB)
|
||||
let mermaidPromise: Promise<typeof import('mermaid').default> | null = null;
|
||||
|
||||
const loadMermaid = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import('mermaid').then((mod) => mod.default);
|
||||
}
|
||||
|
||||
return mermaidPromise;
|
||||
};
|
||||
|
||||
interface UseMermaidOptions {
|
||||
/** Mermaid diagram content */
|
||||
content: string;
|
||||
/** Unique identifier for this diagram */
|
||||
id?: string;
|
||||
/** Custom mermaid theme */
|
||||
theme?: string;
|
||||
/** Custom mermaid configuration */
|
||||
config?: Partial<MermaidConfig>;
|
||||
}
|
||||
|
||||
interface UseMermaidReturn {
|
||||
/** The rendered SVG string */
|
||||
svg: string | undefined;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error object if rendering failed */
|
||||
error: Error | undefined;
|
||||
/** Whether content is being validated */
|
||||
isValidating: boolean;
|
||||
}
|
||||
|
||||
export const useMermaid = ({
|
||||
content,
|
||||
id = DEFAULT_ID_PREFIX,
|
||||
theme: customTheme,
|
||||
config,
|
||||
}: UseMermaidOptions): UseMermaidReturn => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isDarkMode = isDark(theme);
|
||||
|
||||
// Store last valid SVG for fallback on errors
|
||||
const [validContent, setValidContent] = useState<string>('');
|
||||
|
||||
// Generate cache key based on content, theme, and ID
|
||||
const cacheKey = useMemo((): string => {
|
||||
// For large diagrams, use MD5 hash instead of full content
|
||||
const contentHash = content.length < MD5_LENGTH_THRESHOLD ? content : Md5.hashStr(content);
|
||||
|
||||
// Include theme mode in cache key to handle theme switches
|
||||
const themeKey = customTheme || (isDarkMode ? 'd' : 'l');
|
||||
|
||||
return [id, themeKey, contentHash].filter(Boolean).join('-');
|
||||
}, [content, id, isDarkMode, customTheme]);
|
||||
|
||||
// Generate unique diagram ID (mermaid requires unique IDs in the DOM)
|
||||
// Include cacheKey to regenerate when content/theme changes, preventing mermaid internal conflicts
|
||||
const diagramId = useMemo(() => {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(7);
|
||||
return `${id}-${timestamp}-${random}`;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, cacheKey]);
|
||||
|
||||
// Build mermaid configuration
|
||||
const mermaidConfig = useMemo((): MermaidConfig => {
|
||||
const defaultTheme = isDarkMode ? 'dark' : 'neutral';
|
||||
|
||||
return {
|
||||
startOnLoad: false,
|
||||
theme: (customTheme as MermaidConfig['theme']) || defaultTheme,
|
||||
// Spread custom config but override security settings after
|
||||
...config,
|
||||
// Security hardening - these MUST come last to prevent override
|
||||
securityLevel: 'strict', // Highest security: disables click, sanitizes text
|
||||
maxTextSize: config?.maxTextSize ?? 50000, // Limit text size to prevent DoS
|
||||
maxEdges: config?.maxEdges ?? 500, // Limit edges to prevent DoS
|
||||
};
|
||||
}, [customTheme, isDarkMode, config]);
|
||||
|
||||
// Fetch/render function
|
||||
const fetchSvg = async (): Promise<string> => {
|
||||
// SSR guard
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Load mermaid library (cached after first load)
|
||||
const mermaidInstance = await loadMermaid();
|
||||
|
||||
if (!mermaidInstance) {
|
||||
throw new Error('Failed to load mermaid library');
|
||||
}
|
||||
|
||||
// Validate syntax first and capture detailed error
|
||||
try {
|
||||
await mermaidInstance.parse(content);
|
||||
} catch (parseError) {
|
||||
// Extract meaningful error message from mermaid's parse error
|
||||
let errorMessage = 'Invalid mermaid syntax';
|
||||
if (parseError instanceof Error) {
|
||||
errorMessage = parseError.message;
|
||||
} else if (typeof parseError === 'string') {
|
||||
errorMessage = parseError;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Initialize with config
|
||||
mermaidInstance.initialize(mermaidConfig);
|
||||
|
||||
// Render to SVG
|
||||
const { svg } = await mermaidInstance.render(diagramId, content);
|
||||
|
||||
// Sanitize SVG output with DOMPurify for additional security
|
||||
const purify = DOMPurify();
|
||||
const sanitizedSvg = purify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
// Allow additional elements used by mermaid for text rendering
|
||||
ADD_TAGS: ['foreignObject', 'use', 'switch'],
|
||||
ADD_ATTR: [
|
||||
'dominant-baseline',
|
||||
'text-anchor',
|
||||
'requiredFeatures',
|
||||
'systemLanguage',
|
||||
'xmlns:xlink',
|
||||
],
|
||||
});
|
||||
|
||||
// Store as last valid content
|
||||
setValidContent(sanitizedSvg);
|
||||
|
||||
return sanitizedSvg;
|
||||
} catch (error) {
|
||||
console.error('Mermaid rendering error:', error);
|
||||
|
||||
// Return last valid content if available (graceful degradation)
|
||||
if (validContent) {
|
||||
return validContent;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Use SWR for caching and revalidation
|
||||
const { data, error, isLoading, isValidating } = useSWR<string, Error>(cacheKey, fetchSvg, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 3000,
|
||||
errorRetryCount: 2,
|
||||
errorRetryInterval: 1000,
|
||||
shouldRetryOnError: true,
|
||||
});
|
||||
|
||||
return {
|
||||
svg: data,
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMermaid;
|
||||
|
|
@ -9,6 +9,7 @@ export * from './Files';
|
|||
export * from './Generic';
|
||||
export * from './Input';
|
||||
export * from './MCP';
|
||||
export * from './Mermaid';
|
||||
export * from './Messages';
|
||||
export * from './Plugins';
|
||||
export * from './Prompts';
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,7 @@
|
|||
"com_ui_handoff_instructions": "Handoff instructions",
|
||||
"com_ui_happy_birthday": "It's my 1st birthday!",
|
||||
"com_ui_header_format": "Header Format",
|
||||
"com_ui_hide_code": "Hide Code",
|
||||
"com_ui_hide_image_details": "Hide Image Details",
|
||||
"com_ui_hide_password": "Hide password",
|
||||
"com_ui_hide_qr": "Hide QR Code",
|
||||
|
|
@ -1103,6 +1104,9 @@
|
|||
"com_ui_memory_updated": "Updated saved memory",
|
||||
"com_ui_memory_updated_items": "Updated Memories",
|
||||
"com_ui_memory_would_exceed": "Cannot save - would exceed limit by {{tokens}} tokens. Delete existing memories to make space.",
|
||||
"com_ui_mermaid": "mermaid",
|
||||
"com_ui_mermaid_failed": "Failed to render diagram:",
|
||||
"com_ui_mermaid_source": "Source code:",
|
||||
"com_ui_mention": "Mention an endpoint, assistant, or preset to quickly switch to it",
|
||||
"com_ui_message_input": "Message input",
|
||||
"com_ui_microphone_unavailable": "Microphone is not available",
|
||||
|
|
@ -1220,6 +1224,7 @@
|
|||
"com_ui_result": "Result",
|
||||
"com_ui_result_found": "{{count}} result found",
|
||||
"com_ui_results_found": "{{count}} results found",
|
||||
"com_ui_retry": "Retry",
|
||||
"com_ui_revoke": "Revoke",
|
||||
"com_ui_revoke_info": "Revoke all user provided credentials",
|
||||
"com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?",
|
||||
|
|
@ -1293,6 +1298,7 @@
|
|||
"com_ui_shared_prompts": "Shared Prompts",
|
||||
"com_ui_shop": "Shopping",
|
||||
"com_ui_show_all": "Show All",
|
||||
"com_ui_show_code": "Show Code",
|
||||
"com_ui_show_image_details": "Show Image Details",
|
||||
"com_ui_show_password": "Show password",
|
||||
"com_ui_show_qr": "Show QR Code",
|
||||
|
|
|
|||
|
|
@ -110,6 +110,20 @@ export default defineConfig(({ command }) => ({
|
|||
const normalizedId = id.replace(/\\/g, '/');
|
||||
if (normalizedId.includes('node_modules')) {
|
||||
// High-impact chunking for large libraries
|
||||
|
||||
// IMPORTANT: mermaid and ALL its dependencies must be in the same chunk
|
||||
// to avoid initialization order issues. This includes chevrotain, langium,
|
||||
// dagre-d3-es, and their nested lodash-es dependencies.
|
||||
if (
|
||||
normalizedId.includes('mermaid') ||
|
||||
normalizedId.includes('dagre-d3-es') ||
|
||||
normalizedId.includes('chevrotain') ||
|
||||
normalizedId.includes('langium') ||
|
||||
normalizedId.includes('lodash-es')
|
||||
) {
|
||||
return 'mermaid';
|
||||
}
|
||||
|
||||
if (normalizedId.includes('@codesandbox/sandpack')) {
|
||||
return 'sandpack';
|
||||
}
|
||||
|
|
@ -119,7 +133,8 @@ export default defineConfig(({ command }) => ({
|
|||
if (normalizedId.includes('i18next') || normalizedId.includes('react-i18next')) {
|
||||
return 'i18n';
|
||||
}
|
||||
if (normalizedId.includes('lodash')) {
|
||||
// Only regular lodash (not lodash-es which goes to mermaid chunk)
|
||||
if (normalizedId.includes('/lodash/')) {
|
||||
return 'utilities';
|
||||
}
|
||||
if (normalizedId.includes('date-fns')) {
|
||||
|
|
|
|||
1221
package-lock.json
generated
1221
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue