mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
feat: Add Inline Mermaid Diagram Component with Error Handling and Zoom Features
This commit is contained in:
parent
170cc340d8
commit
c53bdc1fef
3 changed files with 477 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -56,6 +56,7 @@ bower_components/
|
|||
.clineignore
|
||||
.cursor
|
||||
.aider*
|
||||
CLAUDE.md
|
||||
|
||||
# Floobits
|
||||
.floo
|
||||
|
|
@ -124,4 +125,4 @@ helm/**/.values.yaml
|
|||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# SAML Idp cert
|
||||
*.cert
|
||||
*.cert
|
||||
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
import React, {
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
import { cn } from '~/utils';
|
||||
import { ThemeContext, isDark } from '~/hooks/ThemeContext';
|
||||
import { ClipboardIcon, CheckIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface InlineMermaidProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InlineMermaidDiagram = memo(({ content, className }: InlineMermaidProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [wasAutoCorrected, setWasAutoCorrected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isDarkMode = isDark(theme);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
|
||||
const diagramKey = useMemo(
|
||||
() => `${content.trim()}-${isDarkMode ? 'dark' : 'light'}`,
|
||||
[content, isDarkMode],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram content:', err);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomIn(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomOut(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform();
|
||||
transformRef.current.centerView(1, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoized to prevent re-renders when content/theme changes
|
||||
const fixCommonSyntaxIssues = useMemo(() => {
|
||||
return (text: string) => {
|
||||
let fixed = text;
|
||||
|
||||
fixed = fixed.replace(/--\s+>/g, '-->');
|
||||
fixed = fixed.replace(/--\s+\|/g, '--|');
|
||||
fixed = fixed.replace(/\|\s+-->/g, '|-->');
|
||||
fixed = fixed.replace(/\[([^[\]]*)"([^[\]]*)"([^[\]]*)\]/g, '[$1$2$3]');
|
||||
fixed = fixed.replace(/subgraph([A-Za-z])/g, 'subgraph $1');
|
||||
|
||||
return fixed;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTryFix = useCallback(() => {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
if (fixedContent !== content) {
|
||||
// Currently just copies the fixed version to clipboard
|
||||
navigator.clipboard.writeText(fixedContent).then(() => {
|
||||
setError(t('com_mermaid_fix_copied'));
|
||||
});
|
||||
}
|
||||
}, [content, fixCommonSyntaxIssues, t]);
|
||||
|
||||
// Use ref to track timeout to prevent stale closures
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear previous SVG content
|
||||
setSvgContent('');
|
||||
|
||||
const cleanContent = content.trim();
|
||||
|
||||
setError(null);
|
||||
setWasAutoCorrected(false);
|
||||
setIsRendered(false);
|
||||
setIsLoading(false);
|
||||
|
||||
if (!cleanContent) {
|
||||
setError(t('com_mermaid_error_no_content'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rendering to avoid flickering during rapid content changes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isCancelled) {
|
||||
renderDiagram();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
async function renderDiagram() {
|
||||
if (isCancelled) return;
|
||||
|
||||
try {
|
||||
if (
|
||||
!cleanContent.match(
|
||||
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitgraph|mindmap|timeline|quadrant|block-beta|sankey|xychart|gitgraph)/i,
|
||||
)
|
||||
) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_type'));
|
||||
setWasAutoCorrected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to reduce bundle size
|
||||
setIsLoading(true);
|
||||
const mermaid = await import('mermaid').then((m) => m.default);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with error suppression to avoid console spam
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkMode ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
logLevel: 'fatal',
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
let result;
|
||||
let contentToRender = cleanContent;
|
||||
|
||||
try {
|
||||
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(id, contentToRender);
|
||||
} catch (_renderError) {
|
||||
const fixedContent = fixCommonSyntaxIssues(cleanContent);
|
||||
if (fixedContent !== cleanContent) {
|
||||
try {
|
||||
const fixedId = `mermaid-fixed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(fixedId, fixedContent);
|
||||
contentToRender = fixedContent;
|
||||
setWasAutoCorrected(true);
|
||||
} catch (_fixedRenderError) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax_auto_correct'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component was unmounted during async render
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.svg) {
|
||||
let processedSvg = result.svg;
|
||||
|
||||
// Enhance SVG for better zoom/pan interaction
|
||||
processedSvg = processedSvg.replace(
|
||||
'<svg',
|
||||
'<svg style="width: 100%; height: auto;" preserveAspectRatio="xMidYMid meet"',
|
||||
);
|
||||
|
||||
// Sanitize SVG content to prevent XSS attacks
|
||||
const sanitizedSvg = DOMPurify.sanitize(processedSvg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['preserveAspectRatio'],
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'iframe'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
setSvgContent(sanitizedSvg);
|
||||
setIsRendered(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_no_svg'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering error:', err);
|
||||
if (!isCancelled) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('com_mermaid_error_rendering_failed', 'Failed to render diagram');
|
||||
setError(t('com_mermaid_error_rendering_failed', { '0': errorMessage }));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [diagramKey, content, isDarkMode, fixCommonSyntaxIssues, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
const canTryFix = fixedContent !== content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 overflow-auto rounded-lg border border-red-300 bg-red-50',
|
||||
'dark:border-red-700 dark:bg-red-900/20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-4 text-red-600 dark:text-red-400">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<strong>{t('com_mermaid_error')}</strong> {error}
|
||||
{canTryFix && (
|
||||
<div className={cn('mt-2 text-sm text-red-500 dark:text-red-300')}>
|
||||
💡 {t('com_mermaid_error_fixes_detected')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex gap-2">
|
||||
{canTryFix && (
|
||||
<button
|
||||
onClick={handleTryFix}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-blue-300 bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
'dark:border-blue-700 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800',
|
||||
)}
|
||||
title={t('com_mermaid_copy_potential_fix')}
|
||||
>
|
||||
{t('com_mermaid_try_fix')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? `✓ ${t('com_mermaid_copied')}` : t('com_mermaid_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
<pre className="overflow-x-auto rounded bg-gray-100 p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{content}</code>
|
||||
</pre>
|
||||
{canTryFix && (
|
||||
<div className="mt-3 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||
<div className={cn('mb-2 text-sm font-medium text-blue-800 dark:text-blue-200')}>
|
||||
{t('com_mermaid_suggested_fix')}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded border bg-white p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{fixedContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={diagramKey}
|
||||
className={cn(
|
||||
'relative my-4 overflow-auto rounded-lg border border-border-light bg-surface-primary',
|
||||
'dark:border-border-heavy dark:bg-surface-primary-alt',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isRendered && wasAutoCorrected && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
'border border-yellow-300 dark:border-yellow-700',
|
||||
'shadow-sm',
|
||||
)}
|
||||
>
|
||||
✨ {t('com_mermaid_auto_fixed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRendered && svgContent && (
|
||||
<div className="absolute right-2 top-2 z-10 flex gap-1">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<ClipboardIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
{(isLoading || !isRendered) && (
|
||||
<div className="animate-pulse text-center text-text-secondary">
|
||||
{t('com_mermaid_rendering')}
|
||||
</div>
|
||||
)}
|
||||
{isRendered && svgContent && (
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
limitToBounds={false}
|
||||
centerOnInit={true}
|
||||
wheel={{ step: 0.1 }}
|
||||
panning={{ velocityDisabled: true }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
minHeight: '200px',
|
||||
maxHeight: '600px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mermaid-container flex min-h-[200px] items-center justify-center"
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InlineMermaidDiagram.displayName = 'InlineMermaidDiagram';
|
||||
|
||||
export default InlineMermaidDiagram;
|
||||
|
|
@ -300,6 +300,26 @@
|
|||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
"com_mermaid_auto_fixed": "Auto-fixed",
|
||||
"com_mermaid_copy": "Copy",
|
||||
"com_mermaid_copy_potential_fix": "Copy potential fix to clipboard",
|
||||
"com_mermaid_copy_code": "Copy mermaid code",
|
||||
"com_mermaid_copied": "Copied",
|
||||
"com_mermaid_error": "Mermaid Error:",
|
||||
"com_mermaid_error_fixes_detected": "Potential fixes detected: spacing issues in arrows or labels",
|
||||
"com_mermaid_error_invalid_syntax": "Invalid diagram syntax - check arrow formatting and node labels",
|
||||
"com_mermaid_error_invalid_syntax_auto_correct": "Invalid diagram syntax - syntax errors found but unable to auto-correct",
|
||||
"com_mermaid_error_invalid_type": "Invalid Mermaid syntax - diagram must start with a valid diagram type (flowchart, graph, sequenceDiagram, etc.)",
|
||||
"com_mermaid_error_no_content": "No diagram content provided",
|
||||
"com_mermaid_error_no_svg": "No SVG generated - rendering failed unexpectedly",
|
||||
"com_mermaid_error_rendering_failed": "Rendering failed: {{0}}",
|
||||
"com_mermaid_fix_copied": "Potential fix copied to clipboard. Common issues found and corrected.",
|
||||
"com_mermaid_rendering": "Rendering diagram...",
|
||||
"com_mermaid_suggested_fix": "Suggested Fix:",
|
||||
"com_mermaid_try_fix": "Try Fix",
|
||||
"com_mermaid_zoom_in": "Zoom in",
|
||||
"com_mermaid_zoom_out": "Zoom out",
|
||||
"com_mermaid_reset_zoom": "Reset zoom",
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue