diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx index 6ed5cfab23..8fd8ae8b59 100644 --- a/web/app/components/base/mermaid/index.tsx +++ b/web/app/components/base/mermaid/index.tsx @@ -1,116 +1,528 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import mermaid from 'mermaid' -import { usePrevious } from 'ahooks' import { useTranslation } from 'react-i18next' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' -import { cleanUpSvgCode } from './utils' +import { MoonIcon, SunIcon } from '@heroicons/react/24/solid' +import { + cleanUpSvgCode, + isMermaidCodeComplete, + prepareMermaidCode, + processSvgForTheme, + svgToBase64, + waitForDOMElement, +} from './utils' import LoadingAnim from '@/app/components/base/chat/chat/loading-anim' import cn from '@/utils/classnames' import ImagePreview from '@/app/components/base/image-uploader/image-preview' +import { Theme } from '@/types/app' -let mermaidAPI: any -mermaidAPI = null +// Global flags and cache for mermaid +let isMermaidInitialized = false +const diagramCache = new Map() +let mermaidAPI: any = null if (typeof window !== 'undefined') mermaidAPI = mermaid.mermaidAPI -const svgToBase64 = (svgGraph: string) => { - const svgBytes = new TextEncoder().encode(svgGraph) - const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' }) - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => resolve(reader.result) - reader.onerror = reject - reader.readAsDataURL(blob) - }) +// Theme configurations +const THEMES = { + light: { + name: 'Light Theme', + background: '#ffffff', + primaryColor: '#ffffff', + primaryBorderColor: '#000000', + primaryTextColor: '#000000', + secondaryColor: '#ffffff', + tertiaryColor: '#ffffff', + nodeColors: [ + { bg: '#f0f9ff', color: '#0369a1' }, + { bg: '#f0fdf4', color: '#166534' }, + { bg: '#fef2f2', color: '#b91c1c' }, + { bg: '#faf5ff', color: '#7e22ce' }, + { bg: '#fffbeb', color: '#b45309' }, + ], + connectionColor: '#74a0e0', + }, + dark: { + name: 'Dark Theme', + background: '#1e293b', + primaryColor: '#334155', + primaryBorderColor: '#94a3b8', + primaryTextColor: '#e2e8f0', + secondaryColor: '#475569', + tertiaryColor: '#334155', + nodeColors: [ + { bg: '#164e63', color: '#e0f2fe' }, + { bg: '#14532d', color: '#dcfce7' }, + { bg: '#7f1d1d', color: '#fee2e2' }, + { bg: '#581c87', color: '#f3e8ff' }, + { bg: '#78350f', color: '#fef3c7' }, + ], + connectionColor: '#60a5fa', + }, } -const Flowchart = ( - { - ref, - ...props - }: { - PrimitiveCode: string - } & { - ref: React.RefObject; - }, -) => { - const { t } = useTranslation() - const [svgCode, setSvgCode] = useState(null) - const [look, setLook] = useState<'classic' | 'handDrawn'>('classic') - - const prevPrimitiveCode = usePrevious(props.PrimitiveCode) - const [isLoading, setIsLoading] = useState(true) - const timeRef = useRef(0) - const [errMsg, setErrMsg] = useState('') - const [imagePreviewUrl, setImagePreviewUrl] = useState('') - - const renderFlowchart = useCallback(async (PrimitiveCode: string) => { - setSvgCode(null) - setIsLoading(true) - +/** + * Initializes mermaid library with default configuration + */ +const initMermaid = () => { + if (typeof window !== 'undefined' && !isMermaidInitialized) { try { - if (typeof window !== 'undefined' && mermaidAPI) { - const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode) - const base64Svg: any = await svgToBase64(cleanUpSvgCode(svgGraph.svg)) - setSvgCode(base64Svg) - setIsLoading(false) - } - } - catch (error) { - if (prevPrimitiveCode === props.PrimitiveCode) { - setIsLoading(false) - setErrMsg((error as Error).message) - } - } - }, [props.PrimitiveCode]) - - useEffect(() => { - if (typeof window !== 'undefined') { mermaid.initialize({ - startOnLoad: true, - theme: 'neutral', - look, + startOnLoad: false, + fontFamily: 'sans-serif', + securityLevel: 'loose', flowchart: { htmlLabels: true, useMaxWidth: true, + diagramPadding: 10, + curve: 'basis', + nodeSpacing: 50, + rankSpacing: 70, }, + gantt: { + titleTopMargin: 25, + barHeight: 20, + barGap: 4, + topPadding: 50, + leftPadding: 75, + gridLineStartPadding: 35, + fontSize: 11, + numberSectionStyles: 4, + axisFormat: '%Y-%m-%d', + }, + maxTextSize: 50000, }) - - renderFlowchart(props.PrimitiveCode) + isMermaidInitialized = true } - }, [look]) + catch (error) { + console.error('Mermaid initialization error:', error) + return null + } + } + return isMermaidInitialized +} +const Flowchart = React.forwardRef((props: { + PrimitiveCode: string + theme?: 'light' | 'dark' +}, ref) => { + const { t } = useTranslation() + const [svgCode, setSvgCode] = useState(null) + const [look, setLook] = useState<'classic' | 'handDrawn'>('classic') + const [isInitialized, setIsInitialized] = useState(false) + const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light') + const containerRef = useRef(null) + const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current + const [isLoading, setIsLoading] = useState(true) + const renderTimeoutRef = useRef() + const [errMsg, setErrMsg] = useState('') + const [imagePreviewUrl, setImagePreviewUrl] = useState('') + const [isCodeComplete, setIsCodeComplete] = useState(false) + const codeCompletionCheckRef = useRef() + + // Create cache key from code, style and theme + const cacheKey = useMemo(() => { + return `${props.PrimitiveCode}-${look}-${currentTheme}` + }, [props.PrimitiveCode, look, currentTheme]) + + /** + * Renders Mermaid chart + */ + const renderMermaidChart = async (code: string, style: 'classic' | 'handDrawn') => { + if (style === 'handDrawn') { + // Special handling for hand-drawn style + if (containerRef.current) + containerRef.current.innerHTML = `
` + await new Promise(resolve => setTimeout(resolve, 30)) + + if (typeof window !== 'undefined' && mermaidAPI) { + // Prefer using mermaidAPI directly for hand-drawn style + return await mermaidAPI.render(chartId, code) + } + else { + // Fall back to standard rendering if mermaidAPI is not available + const { svg } = await mermaid.render(chartId, code) + return { svg } + } + } + else { + // Standard rendering for classic style - using the extracted waitForDOMElement function + const renderWithRetry = async () => { + if (containerRef.current) + containerRef.current.innerHTML = `
` + await new Promise(resolve => setTimeout(resolve, 30)) + const { svg } = await mermaid.render(chartId, code) + return { svg } + } + return await waitForDOMElement(renderWithRetry) + } + } + + /** + * Handle rendering errors + */ + const handleRenderError = (error: any) => { + console.error('Mermaid rendering error:', error) + const errorMsg = (error as Error).message + + if (errorMsg.includes('getAttribute')) { + diagramCache.clear() + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + }) + } + else { + setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`) + } + + if (look === 'handDrawn') { + try { + // Clear possible cache issues + diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`) + + // Reset mermaid configuration + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + theme: 'default', + maxTextSize: 50000, + }) + + // Try rendering with standard mode + setLook('classic') + setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.') + + // Delay error clearing + setTimeout(() => { + if (containerRef.current) { + // Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency + // Instead set state to trigger re-render + setIsCodeComplete(true) // This will trigger useEffect re-render + } + }, 500) + } + catch (e) { + console.error('Reset after handDrawn error failed:', e) + } + } + + setIsLoading(false) + } + + // Initialize mermaid useEffect(() => { - if (timeRef.current) - window.clearTimeout(timeRef.current) + const api = initMermaid() + if (api) + setIsInitialized(true) + }, []) - timeRef.current = window.setTimeout(() => { - renderFlowchart(props.PrimitiveCode) + // Update theme when prop changes + useEffect(() => { + if (props.theme) + setCurrentTheme(props.theme) + }, [props.theme]) + + // Validate mermaid code and check for completeness + useEffect(() => { + if (codeCompletionCheckRef.current) + clearTimeout(codeCompletionCheckRef.current) + + // Reset code complete status when code changes + setIsCodeComplete(false) + + // If no code or code is extremely short, don't proceed + if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) + return + + // Check if code already in cache - if so we know it's valid + if (diagramCache.has(cacheKey)) { + setIsCodeComplete(true) + return + } + + // Initial check using the extracted isMermaidCodeComplete function + const isComplete = isMermaidCodeComplete(props.PrimitiveCode) + if (isComplete) { + setIsCodeComplete(true) + return + } + + // Set a delay to check again in case code is still being generated + codeCompletionCheckRef.current = setTimeout(() => { + setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode)) }, 300) - }, [props.PrimitiveCode]) + + return () => { + if (codeCompletionCheckRef.current) + clearTimeout(codeCompletionCheckRef.current) + } + }, [props.PrimitiveCode, cacheKey]) + + /** + * Renders flowchart based on provided code + */ + const renderFlowchart = useCallback(async (primitiveCode: string) => { + if (!isInitialized || !containerRef.current) { + setIsLoading(false) + setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found') + return + } + + // Don't render if code is not complete yet + if (!isCodeComplete) { + setIsLoading(true) + return + } + + // Return cached result if available + if (diagramCache.has(cacheKey)) { + setSvgCode(diagramCache.get(cacheKey) || null) + setIsLoading(false) + return + } + + setIsLoading(true) + setErrMsg('') + + try { + let finalCode: string + + // Check if it's a gantt chart + const isGanttChart = primitiveCode.trim().startsWith('gantt') + + if (isGanttChart) { + // For gantt charts, ensure each task is on its own line + // and preserve exact whitespace/format + finalCode = primitiveCode.trim() + } + else { + // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function + finalCode = prepareMermaidCode(primitiveCode, look) + } + + // Step 2: Render chart + const svgGraph = await renderMermaidChart(finalCode, look) + + // Step 3: Apply theme to SVG using the extracted processSvgForTheme function + const processedSvg = processSvgForTheme( + svgGraph.svg, + currentTheme === Theme.dark, + look === 'handDrawn', + THEMES, + ) + + // Step 4: Clean SVG code and convert to base64 using the extracted functions + const cleanedSvg = cleanUpSvgCode(processedSvg) + const base64Svg = await svgToBase64(cleanedSvg) + + if (base64Svg && typeof base64Svg === 'string') { + diagramCache.set(cacheKey, base64Svg) + setSvgCode(base64Svg) + } + + setIsLoading(false) + } + catch (error) { + // Error handling + handleRenderError(error) + } + }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t]) + + /** + * Configure mermaid based on selected style and theme + */ + const configureMermaid = useCallback(() => { + if (typeof window !== 'undefined' && isInitialized) { + const themeVars = THEMES[currentTheme] + const config: any = { + startOnLoad: false, + securityLevel: 'loose', + fontFamily: 'sans-serif', + maxTextSize: 50000, + gantt: { + titleTopMargin: 25, + barHeight: 20, + barGap: 4, + topPadding: 50, + leftPadding: 75, + gridLineStartPadding: 35, + fontSize: 11, + numberSectionStyles: 4, + axisFormat: '%Y-%m-%d', + }, + } + + if (look === 'classic') { + config.theme = currentTheme === 'dark' ? 'dark' : 'neutral' + config.flowchart = { + htmlLabels: true, + useMaxWidth: true, + diagramPadding: 12, + nodeSpacing: 60, + rankSpacing: 80, + curve: 'linear', + ranker: 'tight-tree', + } + } + else { + config.theme = 'default' + config.themeCSS = ` + .node rect { fill-opacity: 0.85; } + .edgePath .path { stroke-width: 1.5px; } + .label { font-family: 'sans-serif'; } + .edgeLabel { font-family: 'sans-serif'; } + .cluster rect { rx: 5px; ry: 5px; } + ` + config.themeVariables = { + fontSize: '14px', + fontFamily: 'sans-serif', + } + config.flowchart = { + htmlLabels: true, + useMaxWidth: true, + diagramPadding: 10, + nodeSpacing: 40, + rankSpacing: 60, + curve: 'basis', + } + config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor + } + + if (currentTheme === 'dark' && !config.themeVariables) { + config.themeVariables = { + background: themeVars.background, + primaryColor: themeVars.primaryColor, + primaryBorderColor: themeVars.primaryBorderColor, + primaryTextColor: themeVars.primaryTextColor, + secondaryColor: themeVars.secondaryColor, + tertiaryColor: themeVars.tertiaryColor, + fontFamily: 'sans-serif', + } + } + + try { + mermaid.initialize(config) + return true + } + catch (error) { + console.error('Config error:', error) + return false + } + } + return false + }, [currentTheme, isInitialized, look]) + + // Effect for theme and style configuration + useEffect(() => { + if (diagramCache.has(cacheKey)) { + setSvgCode(diagramCache.get(cacheKey) || null) + setIsLoading(false) + return + } + + if (configureMermaid() && containerRef.current && isCodeComplete) + renderFlowchart(props.PrimitiveCode) + }, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid]) + + // Effect for rendering with debounce + useEffect(() => { + if (diagramCache.has(cacheKey)) { + setSvgCode(diagramCache.get(cacheKey) || null) + setIsLoading(false) + return + } + + if (renderTimeoutRef.current) + clearTimeout(renderTimeoutRef.current) + + if (isCodeComplete) { + renderTimeoutRef.current = setTimeout(() => { + if (isInitialized) + renderFlowchart(props.PrimitiveCode) + }, 300) + } + else { + setIsLoading(true) + } + + return () => { + if (renderTimeoutRef.current) + clearTimeout(renderTimeoutRef.current) + } + }, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (containerRef.current) + containerRef.current.innerHTML = '' + if (renderTimeoutRef.current) + clearTimeout(renderTimeoutRef.current) + if (codeCompletionCheckRef.current) + clearTimeout(codeCompletionCheckRef.current) + } + }, []) + + const toggleTheme = () => { + setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light) + diagramCache.clear() + } + + // Style classes for theme-dependent elements + const themeClasses = { + container: cn('relative', { + 'bg-white': currentTheme === Theme.light, + 'bg-slate-900': currentTheme === Theme.dark, + }), + mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', { + 'bg-white': currentTheme === Theme.light, + 'bg-slate-900': currentTheme === Theme.dark, + }), + errorMessage: cn('py-4 px-[26px]', { + 'text-red-500': currentTheme === Theme.light, + 'text-red-400': currentTheme === Theme.dark, + }), + errorIcon: cn('w-6 h-6', { + 'text-red-500': currentTheme === Theme.light, + 'text-red-400': currentTheme === Theme.dark, + }), + segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', { + 'text-gray-700': currentTheme === Theme.light, + 'text-gray-300': currentTheme === Theme.dark, + }), + themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', { + 'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light, + 'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark, + }), + } + + // Style classes for look options + const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => { + return cn( + 'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium text-text-secondary', + look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', + currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300', + look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white', + ) + } return ( - // eslint-disable-next-line ts/ban-ts-comment - // @ts-expect-error - (
-
+
} className={themeClasses.container}> +
-