diff --git a/.gitignore b/.gitignore index c9658f17e6..984aa3b265 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/client/src/components/Chat/Messages/Content/MermaidDiagram.tsx b/client/src/components/Chat/Messages/Content/MermaidDiagram.tsx new file mode 100644 index 0000000000..1905a3c41b --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MermaidDiagram.tsx @@ -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(''); + const [isRendered, setIsRendered] = useState(false); + const [error, setError] = useState(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(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(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( + ' { + 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 ( +
+
+
+
+ {t('com_mermaid_error')} {error} + {canTryFix && ( +
+ 💡 {t('com_mermaid_error_fixes_detected')} +
+ )} +
+
+ {canTryFix && ( + + )} + +
+
+
+
+
+            {content}
+          
+ {canTryFix && ( +
+
+ {t('com_mermaid_suggested_fix')} +
+
+                {fixedContent}
+              
+
+ )} +
+
+ ); + } + + return ( +
+ {isRendered && wasAutoCorrected && ( +
+ ✨ {t('com_mermaid_auto_fixed')} +
+ )} + + {isRendered && svgContent && ( +
+ + + + +
+ )} + +
+ {(isLoading || !isRendered) && ( +
+ {t('com_mermaid_rendering')} +
+ )} + {isRendered && svgContent && ( + + +
+ + + )} +
+
+ ); +}); + +InlineMermaidDiagram.displayName = 'InlineMermaidDiagram'; + +export default InlineMermaidDiagram; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 69a3f29539..655a5a825a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",