- {(['mermaid', 'svg']).includes(language!) &&
}
+ {language === 'svg' &&
}
diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx
index 31eaffb813..a953ef15a8 100644
--- a/web/app/components/base/mermaid/index.tsx
+++ b/web/app/components/base/mermaid/index.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import mermaid from 'mermaid'
+import mermaid, { type MermaidConfig } from 'mermaid'
import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
@@ -68,14 +68,13 @@ const THEMES = {
const initMermaid = () => {
if (typeof window !== 'undefined' && !isMermaidInitialized) {
try {
- mermaid.initialize({
+ const config: MermaidConfig = {
startOnLoad: false,
fontFamily: 'sans-serif',
securityLevel: 'loose',
flowchart: {
htmlLabels: true,
useMaxWidth: true,
- diagramPadding: 10,
curve: 'basis',
nodeSpacing: 50,
rankSpacing: 70,
@@ -94,10 +93,10 @@ const initMermaid = () => {
mindmap: {
useMaxWidth: true,
padding: 10,
- diagramPadding: 20,
},
maxTextSize: 50000,
- })
+ }
+ mermaid.initialize(config)
isMermaidInitialized = true
}
catch (error) {
@@ -113,7 +112,7 @@ const Flowchart = React.forwardRef((props: {
theme?: 'light' | 'dark'
}, ref) => {
const { t } = useTranslation()
- const [svgCode, setSvgCode] = useState
(null)
+ const [svgString, setSvgString] = useState(null)
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
const [isInitialized, setIsInitialized] = useState(false)
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
@@ -125,6 +124,7 @@ const Flowchart = React.forwardRef((props: {
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef()
+ const prevCodeRef = useRef()
// Create cache key from code, style and theme
const cacheKey = useMemo(() => {
@@ -169,50 +169,18 @@ const Flowchart = React.forwardRef((props: {
*/
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',
- })
+ // On any render error, assume the mermaid state is corrupted and force a re-initialization.
+ try {
+ diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs
+ isMermaidInitialized = false // <-- THE FIX: Force re-initialization
+ initMermaid() // Re-initialize with the default safe configuration
}
- 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)
- }
+ catch (reinitError) {
+ console.error('Failed to re-initialize Mermaid after error:', reinitError)
}
+ setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`)
setIsLoading(false)
}
@@ -223,51 +191,23 @@ const Flowchart = React.forwardRef((props: {
setIsInitialized(true)
}, [])
- // Update theme when prop changes
+ // Update theme when prop changes, but allow internal override.
+ const prevThemeRef = useRef()
useEffect(() => {
- if (props.theme)
+ // Only react if the theme prop from the outside has actually changed.
+ if (props.theme && props.theme !== prevThemeRef.current) {
+ // When the global theme prop changes, it should act as the source of truth,
+ // overriding any local theme selection.
+ diagramCache.clear()
+ setSvgString(null)
setCurrentTheme(props.theme)
+ // Reset look to classic for a consistent state after a global change.
+ setLook('classic')
+ }
+ // Update the ref to the current prop value for the next render.
+ prevThemeRef.current = 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)
-
- 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)
@@ -275,15 +215,11 @@ const Flowchart = React.forwardRef((props: {
return
}
- // Don't render if code is not complete yet
- if (!isCodeComplete) {
- setIsLoading(true)
- return
- }
-
// Return cached result if available
+ const cacheKey = `${primitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) {
- setSvgCode(diagramCache.get(cacheKey) || null)
+ setErrMsg('')
+ setSvgString(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
@@ -294,17 +230,45 @@ const Flowchart = React.forwardRef((props: {
try {
let finalCode: string
- // Check if it's a gantt chart or mindmap
- const isGanttChart = primitiveCode.trim().startsWith('gantt')
- const isMindMap = primitiveCode.trim().startsWith('mindmap')
+ const trimmedCode = primitiveCode.trim()
+ const isGantt = trimmedCode.startsWith('gantt')
+ const isMindMap = trimmedCode.startsWith('mindmap')
+ const isSequence = trimmedCode.startsWith('sequenceDiagram')
- if (isGanttChart || isMindMap) {
- // For gantt charts and mindmaps, ensure each task is on its own line
- // and preserve exact whitespace/format
- finalCode = primitiveCode.trim()
+ if (isGantt || isMindMap || isSequence) {
+ if (isGantt) {
+ finalCode = trimmedCode
+ .split('\n')
+ .map((line) => {
+ // Gantt charts have specific syntax needs.
+ const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
+ if (!taskMatch)
+ return line // Not a task line, return as is.
+
+ const taskName = taskMatch[1].trim()
+ let paramsStr = taskMatch[2].trim()
+
+ // Rule 1: Correct multiple "after" dependencies ONLY if they exist.
+ // This is a common mistake, e.g., "..., after task1, after task2, ..."
+ const afterCount = (paramsStr.match(/after /g) || []).length
+ if (afterCount > 1)
+ paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ')
+
+ // Rule 2: Normalize spacing between parameters for consistency.
+ const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim()
+ return `${taskName} :${finalParams}`
+ })
+ .join('\n')
+ }
+ else {
+ // For mindmap and sequence charts, which are sensitive to syntax,
+ // pass the code through directly.
+ finalCode = trimmedCode
+ }
}
else {
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
+ // This function handles flowcharts appropriately.
finalCode = prepareMermaidCode(primitiveCode, look)
}
@@ -319,13 +283,12 @@ const Flowchart = React.forwardRef((props: {
THEMES,
)
- // Step 4: Clean SVG code and convert to base64 using the extracted functions
+ // Step 4: Clean up SVG code
const cleanedSvg = cleanUpSvgCode(processedSvg)
- const base64Svg = await svgToBase64(cleanedSvg)
- if (base64Svg && typeof base64Svg === 'string') {
- diagramCache.set(cacheKey, base64Svg)
- setSvgCode(base64Svg)
+ if (cleanedSvg && typeof cleanedSvg === 'string') {
+ diagramCache.set(cacheKey, cleanedSvg)
+ setSvgString(cleanedSvg)
}
setIsLoading(false)
@@ -334,12 +297,9 @@ const Flowchart = React.forwardRef((props: {
// Error handling
handleRenderError(error)
}
- }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
+ }, [chartId, isInitialized, look, currentTheme, t])
- /**
- * Configure mermaid based on selected style and theme
- */
- const configureMermaid = useCallback(() => {
+ const configureMermaid = useCallback((primitiveCode: string) => {
if (typeof window !== 'undefined' && isInitialized) {
const themeVars = THEMES[currentTheme]
const config: any = {
@@ -361,23 +321,37 @@ const Flowchart = React.forwardRef((props: {
mindmap: {
useMaxWidth: true,
padding: 10,
- diagramPadding: 20,
},
}
+ const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart')
+
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',
+
+ if (isFlowchart) {
+ config.flowchart = {
+ htmlLabels: true,
+ useMaxWidth: true,
+ nodeSpacing: 60,
+ rankSpacing: 80,
+ curve: 'linear',
+ ranker: 'tight-tree',
+ }
+ }
+
+ if (currentTheme === 'dark') {
+ config.themeVariables = {
+ background: themeVars.background,
+ primaryColor: themeVars.primaryColor,
+ primaryBorderColor: themeVars.primaryBorderColor,
+ primaryTextColor: themeVars.primaryTextColor,
+ secondaryColor: themeVars.secondaryColor,
+ tertiaryColor: themeVars.tertiaryColor,
+ }
}
}
- else {
+ else { // look === 'handDrawn'
config.theme = 'default'
config.themeCSS = `
.node rect { fill-opacity: 0.85; }
@@ -389,27 +363,17 @@ const Flowchart = React.forwardRef((props: {
config.themeVariables = {
fontSize: '14px',
fontFamily: 'sans-serif',
+ primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor,
}
- 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',
+ if (isFlowchart) {
+ config.flowchart = {
+ htmlLabels: true,
+ useMaxWidth: true,
+ nodeSpacing: 40,
+ rankSpacing: 60,
+ curve: 'basis',
+ }
}
}
@@ -425,44 +389,50 @@ const Flowchart = React.forwardRef((props: {
return false
}, [currentTheme, isInitialized, look])
- // Effect for theme and style configuration
+ // This is the main rendering effect.
+ // It triggers whenever the code, theme, or style changes.
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)
+ if (!isInitialized)
+ return
+
+ // Don't render if code is too short
+ if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) {
setIsLoading(false)
+ setSvgString(null)
return
}
+ // Use a timeout to handle streaming code and debounce rendering
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
- if (isCodeComplete) {
- renderTimeoutRef.current = setTimeout(() => {
- if (isInitialized)
- renderFlowchart(props.PrimitiveCode)
- }, 300)
- }
- else {
- setIsLoading(true)
- }
+ setIsLoading(true)
+
+ renderTimeoutRef.current = setTimeout(() => {
+ // Final validation before rendering
+ if (!isMermaidCodeComplete(props.PrimitiveCode)) {
+ setIsLoading(false)
+ setErrMsg('Diagram code is not complete or invalid.')
+ return
+ }
+
+ const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
+ if (diagramCache.has(cacheKey)) {
+ setErrMsg('')
+ setSvgString(diagramCache.get(cacheKey) || null)
+ setIsLoading(false)
+ return
+ }
+
+ if (configureMermaid(props.PrimitiveCode))
+ renderFlowchart(props.PrimitiveCode)
+ }, 300) // 300ms debounce
return () => {
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
}
- }, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
+ }, [props.PrimitiveCode, look, currentTheme, isInitialized, configureMermaid, renderFlowchart])
// Cleanup on unmount
useEffect(() => {
@@ -471,14 +441,22 @@ const Flowchart = React.forwardRef((props: {
containerRef.current.innerHTML = ''
if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current)
- if (codeCompletionCheckRef.current)
- clearTimeout(codeCompletionCheckRef.current)
}
}, [])
+ const handlePreviewClick = async () => {
+ if (svgString) {
+ const base64 = await svgToBase64(svgString)
+ setImagePreviewUrl(base64)
+ }
+ }
+
const toggleTheme = () => {
- setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light'
+ // Ensure a full, clean re-render cycle, consistent with global theme change.
diagramCache.clear()
+ setSvgString(null)
+ setCurrentTheme(newTheme)
}
// Style classes for theme-dependent elements
@@ -527,14 +505,26 @@ const Flowchart = React.forwardRef((props: {
setLook('classic')}
+ onClick={() => {
+ if (look !== 'classic') {
+ diagramCache.clear()
+ setSvgString(null)
+ setLook('classic')
+ }
+ }}
>
{t('app.mermaid.classic')}
setLook('handDrawn')}
+ onClick={() => {
+ if (look !== 'handDrawn') {
+ diagramCache.clear()
+ setSvgString(null)
+ setLook('handDrawn')
+ }
+ }}
>
{t('app.mermaid.handDrawn')}
@@ -544,7 +534,7 @@ const Flowchart = React.forwardRef((props: {
- {isLoading && !svgCode && (
+ {isLoading && !svgString && (
{!isCodeComplete && (
@@ -555,8 +545,8 @@ const Flowchart = React.forwardRef((props: {
)}
- {svgCode && (
- setImagePreviewUrl(svgCode)}>
+ {svgString && (
+
-

{ setErrMsg('Chart rendering failed, please refresh and retry') }}
+ dangerouslySetInnerHTML={{ __html: svgString }}
/>
)}
diff --git a/web/app/components/base/mermaid/utils.ts b/web/app/components/base/mermaid/utils.ts
index 9936a9fc59..9d56494227 100644
--- a/web/app/components/base/mermaid/utils.ts
+++ b/web/app/components/base/mermaid/utils.ts
@@ -3,52 +3,31 @@ export function cleanUpSvgCode(svgCode: string): string {
}
/**
- * Preprocesses mermaid code to fix common syntax issues
+ * Prepares mermaid code for rendering by sanitizing common syntax issues.
+ * @param {string} mermaidCode - The mermaid code to prepare
+ * @param {'classic' | 'handDrawn'} style - The rendering style
+ * @returns {string} - The prepared mermaid code
*/
-export function preprocessMermaidCode(code: string): string {
- if (!code || typeof code !== 'string')
+export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'handDrawn'): string => {
+ if (!mermaidCode || typeof mermaidCode !== 'string')
return ''
- // First check if this is a gantt chart
- if (code.trim().startsWith('gantt')) {
- // For gantt charts, we need to ensure each task is on its own line
- // Split the code into lines and process each line separately
- const lines = code.split('\n').map(line => line.trim())
- return lines.join('\n')
- }
+ let code = mermaidCode.trim()
- return code
- // Replace English colons with Chinese colons in section nodes to avoid parsing issues
- .replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
- // Fix common syntax issues
- .replace(/fifopacket/g, 'rect')
- // Ensure graph has direction
- .replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
- return direction ? match : 'graph TD'
- })
- // Clean up empty lines and extra spaces
- .trim()
-}
+ // Security: Sanitize against javascript: protocol in click events (XSS vector)
+ code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2')
-/**
- * Prepares mermaid code based on selected style
- */
-export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
- let finalCode = preprocessMermaidCode(code)
+ // Convenience: Basic BR replacement. This is a common and safe operation.
+ code = code.replace(/
/g, '\n')
- // Special handling for gantt charts and mindmaps
- if (finalCode.trim().startsWith('gantt') || finalCode.trim().startsWith('mindmap')) {
- // For gantt charts and mindmaps, preserve the structure exactly as is
- return finalCode
- }
+ let finalCode = code
+ // Hand-drawn style requires some specific clean-up.
if (style === 'handDrawn') {
finalCode = finalCode
- // Remove style definitions that interfere with hand-drawn style
.replace(/style\s+[^\n]+/g, '')
.replace(/linkStyle\s+[^\n]+/g, '')
.replace(/^flowchart/, 'graph')
- // Remove any styles that might interfere with hand-drawn style
.replace(/class="[^"]*"/g, '')
.replace(/fill="[^"]*"/g, '')
.replace(/stroke="[^"]*"/g, '')
@@ -82,7 +61,6 @@ export function svgToBase64(svgGraph: string): Promise
{
})
}
catch (error) {
- console.error('Error converting SVG to base64:', error)
return Promise.resolve('')
}
}
@@ -115,13 +93,11 @@ export function processSvgForTheme(
}
else {
let i = 0
- themes.dark.nodeColors.forEach(() => {
- const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
- processedSvg = processedSvg.replace(regex, (match: string) => {
- const colorIndex = i % themes.dark.nodeColors.length
- i++
- return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
- })
+ const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
+ processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
+ const colorIndex = i % themes.dark.nodeColors.length
+ i++
+ return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
})
processedSvg = processedSvg
@@ -139,14 +115,12 @@ export function processSvgForTheme(
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
}
else {
- themes.light.nodeColors.forEach(() => {
- const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
- let i = 0
- processedSvg = processedSvg.replace(regex, (match: string) => {
- const colorIndex = i % themes.light.nodeColors.length
- i++
- return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
- })
+ let i = 0
+ const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
+ processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
+ const colorIndex = i % themes.light.nodeColors.length
+ i++
+ return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
})
processedSvg = processedSvg
@@ -187,24 +161,10 @@ export function isMermaidCodeComplete(code: string): boolean {
// Check for basic syntax structure
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode)
- // Check for balanced brackets and parentheses
- const isBalanced = (() => {
- const stack = []
- const pairs = { '{': '}', '[': ']', '(': ')' }
-
- for (const char of trimmedCode) {
- if (char in pairs) {
- stack.push(char)
- }
- else if (Object.values(pairs).includes(char)) {
- const last = stack.pop()
- if (pairs[last as keyof typeof pairs] !== char)
- return false
- }
- }
-
- return stack.length === 0
- })()
+ // The balanced bracket check was too strict and produced false negatives for valid
+ // mermaid syntax like the asymmetric shape `A>B]`. Relying on Mermaid's own
+ // parser is more robust.
+ const isBalanced = true
// Check for common syntax errors
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
@@ -215,7 +175,7 @@ export function isMermaidCodeComplete(code: string): boolean {
return hasValidStart && isBalanced && hasNoSyntaxErrors
}
catch (error) {
- console.debug('Mermaid code validation error:', error)
+ console.error('Mermaid code validation error:', error)
return false
}
}