You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  2. import mermaid from 'mermaid'
  3. import { useTranslation } from 'react-i18next'
  4. import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
  5. import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
  6. import {
  7. cleanUpSvgCode,
  8. isMermaidCodeComplete,
  9. prepareMermaidCode,
  10. processSvgForTheme,
  11. svgToBase64,
  12. waitForDOMElement,
  13. } from './utils'
  14. import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
  15. import cn from '@/utils/classnames'
  16. import ImagePreview from '@/app/components/base/image-uploader/image-preview'
  17. import { Theme } from '@/types/app'
  18. // Global flags and cache for mermaid
  19. let isMermaidInitialized = false
  20. const diagramCache = new Map<string, string>()
  21. let mermaidAPI: any = null
  22. if (typeof window !== 'undefined')
  23. mermaidAPI = mermaid.mermaidAPI
  24. // Theme configurations
  25. const THEMES = {
  26. light: {
  27. name: 'Light Theme',
  28. background: '#ffffff',
  29. primaryColor: '#ffffff',
  30. primaryBorderColor: '#000000',
  31. primaryTextColor: '#000000',
  32. secondaryColor: '#ffffff',
  33. tertiaryColor: '#ffffff',
  34. nodeColors: [
  35. { bg: '#f0f9ff', color: '#0369a1' },
  36. { bg: '#f0fdf4', color: '#166534' },
  37. { bg: '#fef2f2', color: '#b91c1c' },
  38. { bg: '#faf5ff', color: '#7e22ce' },
  39. { bg: '#fffbeb', color: '#b45309' },
  40. ],
  41. connectionColor: '#74a0e0',
  42. },
  43. dark: {
  44. name: 'Dark Theme',
  45. background: '#1e293b',
  46. primaryColor: '#334155',
  47. primaryBorderColor: '#94a3b8',
  48. primaryTextColor: '#e2e8f0',
  49. secondaryColor: '#475569',
  50. tertiaryColor: '#334155',
  51. nodeColors: [
  52. { bg: '#164e63', color: '#e0f2fe' },
  53. { bg: '#14532d', color: '#dcfce7' },
  54. { bg: '#7f1d1d', color: '#fee2e2' },
  55. { bg: '#581c87', color: '#f3e8ff' },
  56. { bg: '#78350f', color: '#fef3c7' },
  57. ],
  58. connectionColor: '#60a5fa',
  59. },
  60. }
  61. /**
  62. * Initializes mermaid library with default configuration
  63. */
  64. const initMermaid = () => {
  65. if (typeof window !== 'undefined' && !isMermaidInitialized) {
  66. try {
  67. mermaid.initialize({
  68. startOnLoad: false,
  69. fontFamily: 'sans-serif',
  70. securityLevel: 'loose',
  71. flowchart: {
  72. htmlLabels: true,
  73. useMaxWidth: true,
  74. diagramPadding: 10,
  75. curve: 'basis',
  76. nodeSpacing: 50,
  77. rankSpacing: 70,
  78. },
  79. gantt: {
  80. titleTopMargin: 25,
  81. barHeight: 20,
  82. barGap: 4,
  83. topPadding: 50,
  84. leftPadding: 75,
  85. gridLineStartPadding: 35,
  86. fontSize: 11,
  87. numberSectionStyles: 4,
  88. axisFormat: '%Y-%m-%d',
  89. },
  90. mindmap: {
  91. useMaxWidth: true,
  92. padding: 10,
  93. diagramPadding: 20,
  94. },
  95. maxTextSize: 50000,
  96. })
  97. isMermaidInitialized = true
  98. }
  99. catch (error) {
  100. console.error('Mermaid initialization error:', error)
  101. return null
  102. }
  103. }
  104. return isMermaidInitialized
  105. }
  106. const Flowchart = React.forwardRef((props: {
  107. PrimitiveCode: string
  108. theme?: 'light' | 'dark'
  109. }, ref) => {
  110. const { t } = useTranslation()
  111. const [svgCode, setSvgCode] = useState<string | null>(null)
  112. const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
  113. const [isInitialized, setIsInitialized] = useState(false)
  114. const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
  115. const containerRef = useRef<HTMLDivElement>(null)
  116. const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
  117. const [isLoading, setIsLoading] = useState(true)
  118. const renderTimeoutRef = useRef<NodeJS.Timeout>()
  119. const [errMsg, setErrMsg] = useState('')
  120. const [imagePreviewUrl, setImagePreviewUrl] = useState('')
  121. const [isCodeComplete, setIsCodeComplete] = useState(false)
  122. const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
  123. // Create cache key from code, style and theme
  124. const cacheKey = useMemo(() => {
  125. return `${props.PrimitiveCode}-${look}-${currentTheme}`
  126. }, [props.PrimitiveCode, look, currentTheme])
  127. /**
  128. * Renders Mermaid chart
  129. */
  130. const renderMermaidChart = async (code: string, style: 'classic' | 'handDrawn') => {
  131. if (style === 'handDrawn') {
  132. // Special handling for hand-drawn style
  133. if (containerRef.current)
  134. containerRef.current.innerHTML = `<div id="${chartId}"></div>`
  135. await new Promise(resolve => setTimeout(resolve, 30))
  136. if (typeof window !== 'undefined' && mermaidAPI) {
  137. // Prefer using mermaidAPI directly for hand-drawn style
  138. return await mermaidAPI.render(chartId, code)
  139. }
  140. else {
  141. // Fall back to standard rendering if mermaidAPI is not available
  142. const { svg } = await mermaid.render(chartId, code)
  143. return { svg }
  144. }
  145. }
  146. else {
  147. // Standard rendering for classic style - using the extracted waitForDOMElement function
  148. const renderWithRetry = async () => {
  149. if (containerRef.current)
  150. containerRef.current.innerHTML = `<div id="${chartId}"></div>`
  151. await new Promise(resolve => setTimeout(resolve, 30))
  152. const { svg } = await mermaid.render(chartId, code)
  153. return { svg }
  154. }
  155. return await waitForDOMElement(renderWithRetry)
  156. }
  157. }
  158. /**
  159. * Handle rendering errors
  160. */
  161. const handleRenderError = (error: any) => {
  162. console.error('Mermaid rendering error:', error)
  163. const errorMsg = (error as Error).message
  164. if (errorMsg.includes('getAttribute')) {
  165. diagramCache.clear()
  166. mermaid.initialize({
  167. startOnLoad: false,
  168. securityLevel: 'loose',
  169. })
  170. }
  171. else {
  172. setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
  173. }
  174. if (look === 'handDrawn') {
  175. try {
  176. // Clear possible cache issues
  177. diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
  178. // Reset mermaid configuration
  179. mermaid.initialize({
  180. startOnLoad: false,
  181. securityLevel: 'loose',
  182. theme: 'default',
  183. maxTextSize: 50000,
  184. })
  185. // Try rendering with standard mode
  186. setLook('classic')
  187. setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
  188. // Delay error clearing
  189. setTimeout(() => {
  190. if (containerRef.current) {
  191. // Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
  192. // Instead set state to trigger re-render
  193. setIsCodeComplete(true) // This will trigger useEffect re-render
  194. }
  195. }, 500)
  196. }
  197. catch (e) {
  198. console.error('Reset after handDrawn error failed:', e)
  199. }
  200. }
  201. setIsLoading(false)
  202. }
  203. // Initialize mermaid
  204. useEffect(() => {
  205. const api = initMermaid()
  206. if (api)
  207. setIsInitialized(true)
  208. }, [])
  209. // Update theme when prop changes
  210. useEffect(() => {
  211. if (props.theme)
  212. setCurrentTheme(props.theme)
  213. }, [props.theme])
  214. // Validate mermaid code and check for completeness
  215. useEffect(() => {
  216. if (codeCompletionCheckRef.current)
  217. clearTimeout(codeCompletionCheckRef.current)
  218. // Reset code complete status when code changes
  219. setIsCodeComplete(false)
  220. // If no code or code is extremely short, don't proceed
  221. if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
  222. return
  223. // Check if code already in cache - if so we know it's valid
  224. if (diagramCache.has(cacheKey)) {
  225. setIsCodeComplete(true)
  226. return
  227. }
  228. // Initial check using the extracted isMermaidCodeComplete function
  229. const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
  230. if (isComplete) {
  231. setIsCodeComplete(true)
  232. return
  233. }
  234. // Set a delay to check again in case code is still being generated
  235. codeCompletionCheckRef.current = setTimeout(() => {
  236. setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
  237. }, 300)
  238. return () => {
  239. if (codeCompletionCheckRef.current)
  240. clearTimeout(codeCompletionCheckRef.current)
  241. }
  242. }, [props.PrimitiveCode, cacheKey])
  243. /**
  244. * Renders flowchart based on provided code
  245. */
  246. const renderFlowchart = useCallback(async (primitiveCode: string) => {
  247. if (!isInitialized || !containerRef.current) {
  248. setIsLoading(false)
  249. setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found')
  250. return
  251. }
  252. // Don't render if code is not complete yet
  253. if (!isCodeComplete) {
  254. setIsLoading(true)
  255. return
  256. }
  257. // Return cached result if available
  258. if (diagramCache.has(cacheKey)) {
  259. setSvgCode(diagramCache.get(cacheKey) || null)
  260. setIsLoading(false)
  261. return
  262. }
  263. setIsLoading(true)
  264. setErrMsg('')
  265. try {
  266. let finalCode: string
  267. // Check if it's a gantt chart or mindmap
  268. const isGanttChart = primitiveCode.trim().startsWith('gantt')
  269. const isMindMap = primitiveCode.trim().startsWith('mindmap')
  270. if (isGanttChart || isMindMap) {
  271. // For gantt charts and mindmaps, ensure each task is on its own line
  272. // and preserve exact whitespace/format
  273. finalCode = primitiveCode.trim()
  274. }
  275. else {
  276. // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
  277. finalCode = prepareMermaidCode(primitiveCode, look)
  278. }
  279. // Step 2: Render chart
  280. const svgGraph = await renderMermaidChart(finalCode, look)
  281. // Step 3: Apply theme to SVG using the extracted processSvgForTheme function
  282. const processedSvg = processSvgForTheme(
  283. svgGraph.svg,
  284. currentTheme === Theme.dark,
  285. look === 'handDrawn',
  286. THEMES,
  287. )
  288. // Step 4: Clean SVG code and convert to base64 using the extracted functions
  289. const cleanedSvg = cleanUpSvgCode(processedSvg)
  290. const base64Svg = await svgToBase64(cleanedSvg)
  291. if (base64Svg && typeof base64Svg === 'string') {
  292. diagramCache.set(cacheKey, base64Svg)
  293. setSvgCode(base64Svg)
  294. }
  295. setIsLoading(false)
  296. }
  297. catch (error) {
  298. // Error handling
  299. handleRenderError(error)
  300. }
  301. }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
  302. /**
  303. * Configure mermaid based on selected style and theme
  304. */
  305. const configureMermaid = useCallback(() => {
  306. if (typeof window !== 'undefined' && isInitialized) {
  307. const themeVars = THEMES[currentTheme]
  308. const config: any = {
  309. startOnLoad: false,
  310. securityLevel: 'loose',
  311. fontFamily: 'sans-serif',
  312. maxTextSize: 50000,
  313. gantt: {
  314. titleTopMargin: 25,
  315. barHeight: 20,
  316. barGap: 4,
  317. topPadding: 50,
  318. leftPadding: 75,
  319. gridLineStartPadding: 35,
  320. fontSize: 11,
  321. numberSectionStyles: 4,
  322. axisFormat: '%Y-%m-%d',
  323. },
  324. mindmap: {
  325. useMaxWidth: true,
  326. padding: 10,
  327. diagramPadding: 20,
  328. },
  329. }
  330. if (look === 'classic') {
  331. config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
  332. config.flowchart = {
  333. htmlLabels: true,
  334. useMaxWidth: true,
  335. diagramPadding: 12,
  336. nodeSpacing: 60,
  337. rankSpacing: 80,
  338. curve: 'linear',
  339. ranker: 'tight-tree',
  340. }
  341. }
  342. else {
  343. config.theme = 'default'
  344. config.themeCSS = `
  345. .node rect { fill-opacity: 0.85; }
  346. .edgePath .path { stroke-width: 1.5px; }
  347. .label { font-family: 'sans-serif'; }
  348. .edgeLabel { font-family: 'sans-serif'; }
  349. .cluster rect { rx: 5px; ry: 5px; }
  350. `
  351. config.themeVariables = {
  352. fontSize: '14px',
  353. fontFamily: 'sans-serif',
  354. }
  355. config.flowchart = {
  356. htmlLabels: true,
  357. useMaxWidth: true,
  358. diagramPadding: 10,
  359. nodeSpacing: 40,
  360. rankSpacing: 60,
  361. curve: 'basis',
  362. }
  363. config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
  364. }
  365. if (currentTheme === 'dark' && !config.themeVariables) {
  366. config.themeVariables = {
  367. background: themeVars.background,
  368. primaryColor: themeVars.primaryColor,
  369. primaryBorderColor: themeVars.primaryBorderColor,
  370. primaryTextColor: themeVars.primaryTextColor,
  371. secondaryColor: themeVars.secondaryColor,
  372. tertiaryColor: themeVars.tertiaryColor,
  373. fontFamily: 'sans-serif',
  374. }
  375. }
  376. try {
  377. mermaid.initialize(config)
  378. return true
  379. }
  380. catch (error) {
  381. console.error('Config error:', error)
  382. return false
  383. }
  384. }
  385. return false
  386. }, [currentTheme, isInitialized, look])
  387. // Effect for theme and style configuration
  388. useEffect(() => {
  389. if (diagramCache.has(cacheKey)) {
  390. setSvgCode(diagramCache.get(cacheKey) || null)
  391. setIsLoading(false)
  392. return
  393. }
  394. if (configureMermaid() && containerRef.current && isCodeComplete)
  395. renderFlowchart(props.PrimitiveCode)
  396. }, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
  397. // Effect for rendering with debounce
  398. useEffect(() => {
  399. if (diagramCache.has(cacheKey)) {
  400. setSvgCode(diagramCache.get(cacheKey) || null)
  401. setIsLoading(false)
  402. return
  403. }
  404. if (renderTimeoutRef.current)
  405. clearTimeout(renderTimeoutRef.current)
  406. if (isCodeComplete) {
  407. renderTimeoutRef.current = setTimeout(() => {
  408. if (isInitialized)
  409. renderFlowchart(props.PrimitiveCode)
  410. }, 300)
  411. }
  412. else {
  413. setIsLoading(true)
  414. }
  415. return () => {
  416. if (renderTimeoutRef.current)
  417. clearTimeout(renderTimeoutRef.current)
  418. }
  419. }, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
  420. // Cleanup on unmount
  421. useEffect(() => {
  422. return () => {
  423. if (containerRef.current)
  424. containerRef.current.innerHTML = ''
  425. if (renderTimeoutRef.current)
  426. clearTimeout(renderTimeoutRef.current)
  427. if (codeCompletionCheckRef.current)
  428. clearTimeout(codeCompletionCheckRef.current)
  429. }
  430. }, [])
  431. const toggleTheme = () => {
  432. setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
  433. diagramCache.clear()
  434. }
  435. // Style classes for theme-dependent elements
  436. const themeClasses = {
  437. container: cn('relative', {
  438. 'bg-white': currentTheme === Theme.light,
  439. 'bg-slate-900': currentTheme === Theme.dark,
  440. }),
  441. mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', {
  442. 'bg-white': currentTheme === Theme.light,
  443. 'bg-slate-900': currentTheme === Theme.dark,
  444. }),
  445. errorMessage: cn('py-4 px-[26px]', {
  446. 'text-red-500': currentTheme === Theme.light,
  447. 'text-red-400': currentTheme === Theme.dark,
  448. }),
  449. errorIcon: cn('w-6 h-6', {
  450. 'text-red-500': currentTheme === Theme.light,
  451. 'text-red-400': currentTheme === Theme.dark,
  452. }),
  453. segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', {
  454. 'text-gray-700': currentTheme === Theme.light,
  455. 'text-gray-300': currentTheme === Theme.dark,
  456. }),
  457. themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', {
  458. 'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
  459. 'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
  460. }),
  461. }
  462. // Style classes for look options
  463. const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
  464. return cn(
  465. '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',
  466. look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
  467. currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
  468. look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
  469. )
  470. }
  471. return (
  472. <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
  473. <div className={themeClasses.segmented}>
  474. <div className="msh-segmented-group">
  475. <label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]">
  476. <div
  477. key='classic'
  478. className={getLookButtonClass('classic')}
  479. onClick={() => setLook('classic')}
  480. >
  481. <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
  482. </div>
  483. <div
  484. key='handDrawn'
  485. className={getLookButtonClass('handDrawn')}
  486. onClick={() => setLook('handDrawn')}
  487. >
  488. <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
  489. </div>
  490. </label>
  491. </div>
  492. </div>
  493. <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
  494. {isLoading && !svgCode && (
  495. <div className='py-4 px-[26px]'>
  496. <LoadingAnim type='text'/>
  497. {!isCodeComplete && (
  498. <div className="mt-2 text-sm text-gray-500">
  499. {t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
  500. </div>
  501. )}
  502. </div>
  503. )}
  504. {svgCode && (
  505. <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
  506. <div className="absolute left-2 bottom-2 z-[100]">
  507. <button
  508. onClick={(e) => {
  509. e.stopPropagation()
  510. toggleTheme()
  511. }}
  512. className={themeClasses.themeToggle}
  513. title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
  514. style={{ transform: 'translate3d(0, 0, 0)' }}
  515. >
  516. {currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
  517. </button>
  518. </div>
  519. <img
  520. src={svgCode}
  521. alt="mermaid_chart"
  522. style={{ maxWidth: '100%' }}
  523. onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
  524. />
  525. </div>
  526. )}
  527. {errMsg && (
  528. <div className={themeClasses.errorMessage}>
  529. <div className="flex items-center">
  530. <ExclamationTriangleIcon className={themeClasses.errorIcon}/>
  531. <span className="ml-2">{errMsg}</span>
  532. </div>
  533. </div>
  534. )}
  535. {imagePreviewUrl && (
  536. <ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
  537. )}
  538. </div>
  539. )
  540. })
  541. Flowchart.displayName = 'Flowchart'
  542. export default Flowchart