wwf
3 天以前 a430284aa21e3ae1f0d5654e55b2ad2852519cc2
app/components/base/mermaid/index.tsx
@@ -1,528 +1,108 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, 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 { 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'
// Global flags and cache for mermaid
let isMermaidInitialized = false
const diagramCache = new Map<string, string>()
let mermaidAPI: any = null
let mermaidAPI: any
mermaidAPI = null
if (typeof window !== 'undefined')
  mermaidAPI = mermaid.mermaidAPI
// 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',
  },
}
/**
 * Initializes mermaid library with default configuration
 */
const initMermaid = () => {
  if (typeof window !== 'undefined' && !isMermaidInitialized) {
    try {
      mermaid.initialize({
        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,
      })
      isMermaidInitialized = true
    }
    catch (error) {
      console.error('Mermaid initialization error:', error)
      return null
    }
  }
  return isMermaidInitialized
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)
  })
}
const Flowchart = React.forwardRef((props: {
  PrimitiveCode: string
  theme?: 'light' | 'dark'
}, ref) => {
  const { t } = useTranslation()
  const [svgCode, setSvgCode] = useState<string | null>(null)
  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<HTMLDivElement>(null)
  const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
  const prevPrimitiveCode = usePrevious(props.PrimitiveCode)
  const [isLoading, setIsLoading] = useState(true)
  const renderTimeoutRef = useRef<NodeJS.Timeout>()
  const timeRef = useRef<NodeJS.Timeout>()
  const [errMsg, setErrMsg] = useState('')
  const [imagePreviewUrl, setImagePreviewUrl] = useState('')
  const [isCodeComplete, setIsCodeComplete] = useState(false)
  const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
  // 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 = `<div id="${chartId}"></div>`
      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 = `<div id="${chartId}"></div>`
        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(() => {
    const api = initMermaid()
    if (api)
      setIsInitialized(true)
  }, [])
  // 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)
    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
    }
  const renderFlowchart = useCallback(async (PrimitiveCode: string) => {
    setSvgCode(null)
    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)
      if (typeof window !== 'undefined' && mermaidAPI) {
        const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode)
        const base64Svg: any = await svgToBase64(svgGraph.svg)
        setSvgCode(base64Svg)
        setIsLoading(false)
      }
      setIsLoading(false)
    }
    catch (error) {
      // Error handling
      handleRenderError(error)
      if (prevPrimitiveCode === props.PrimitiveCode) {
        setIsLoading(false)
        setErrMsg((error as Error).message)
      }
    }
  }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
  }, [props.PrimitiveCode])
  /**
   * 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',
  useEffect(() => {
    if (typeof window !== 'undefined') {
      mermaid.initialize({
        startOnLoad: true,
        theme: 'neutral',
        look,
        flowchart: {
          htmlLabels: true,
          useMaxWidth: true,
        },
      }
      })
      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])
    }
  }, [look])
  // Effect for rendering with debounce
  useEffect(() => {
    if (diagramCache.has(cacheKey)) {
      setSvgCode(diagramCache.get(cacheKey) || null)
      setIsLoading(false)
      return
    }
    if (timeRef.current)
      clearTimeout(timeRef.current)
    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 relative h-auto w-full cursor-pointer', {
      'bg-white': currentTheme === Theme.light,
      'bg-slate-900': currentTheme === Theme.dark,
    }),
    errorMessage: cn('px-[26px] py-4', {
      'text-red-500': currentTheme === Theme.light,
      'text-red-400': currentTheme === Theme.dark,
    }),
    errorIcon: cn('h-6 w-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 h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', {
      '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(
      'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg 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',
    )
  }
    timeRef.current = setTimeout(() => {
      renderFlowchart(props.PrimitiveCode)
    }, 300)
  }, [props.PrimitiveCode])
  return (
    <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
      <div className={themeClasses.segmented}>
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-expect-error
    <div ref={ref}>
      <div className="msh-segmented msh-segmented-sm css-23bs09 css-var-r1">
        <div className="msh-segmented-group">
          <label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
            <div
              key='classic'
              className={getLookButtonClass('classic')}
          <label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]">
            <div key='classic'
              className={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 === 'classic' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
              )}
              onClick={() => setLook('classic')}
            >
              <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
            </div>
            <div
              key='handDrawn'
              className={getLookButtonClass('handDrawn')}
            <div key='handDrawn'
              className={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 === 'handDrawn' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
              )}
              onClick={() => setLook('handDrawn')}
            >
              <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
@@ -530,61 +110,30 @@
          </label>
        </div>
      </div>
      <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
      {isLoading && !svgCode && (
        <div className='px-[26px] py-4'>
          <LoadingAnim type='text'/>
          {!isCodeComplete && (
            <div className="mt-2 text-sm text-gray-500">
              {t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
      {
        svgCode
            && <div className="mermaid cursor-pointer h-auto w-full object-fit: cover" onClick={() => setImagePreviewUrl(svgCode)}>
              {svgCode && <img src={svgCode} alt="mermaid_chart" />}
            </div>
          )}
        </div>
      )}
      {svgCode && (
        <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
          <div className="absolute bottom-2 left-2 z-[100]">
            <button
              onClick={(e) => {
                e.stopPropagation()
                toggleTheme()
              }}
              className={themeClasses.themeToggle}
              title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
              style={{ transform: 'translate3d(0, 0, 0)' }}
            >
              {currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
            </button>
          </div>
          <img
            src={svgCode}
            alt="mermaid_chart"
            style={{ maxWidth: '100%' }}
            onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
          />
        </div>
      )}
      {errMsg && (
        <div className={themeClasses.errorMessage}>
          <div className="flex items-center">
            <ExclamationTriangleIcon className={themeClasses.errorIcon}/>
            <span className="ml-2">{errMsg}</span>
          </div>
        </div>
      )}
      {imagePreviewUrl && (
        <ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
      )}
      }
      {isLoading
            && <div className='py-4 px-[26px]'>
              <LoadingAnim type='text'/>
            </div>
      }
      {
        errMsg
            && <div className='py-4 px-[26px]'>
              <ExclamationTriangleIcon className='w-6 h-6 text-red-500'/>
              &nbsp;
              {errMsg}
            </div>
      }
      {
        imagePreviewUrl && (<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />)
      }
    </div>
  )
})
Flowchart.displayName = 'Flowchart'
export default Flowchart