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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  2. import mermaid, { type MermaidConfig } 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. const config: MermaidConfig = {
  68. startOnLoad: false,
  69. fontFamily: 'sans-serif',
  70. securityLevel: 'loose',
  71. flowchart: {
  72. htmlLabels: true,
  73. useMaxWidth: true,
  74. curve: 'basis',
  75. nodeSpacing: 50,
  76. rankSpacing: 70,
  77. },
  78. gantt: {
  79. titleTopMargin: 25,
  80. barHeight: 20,
  81. barGap: 4,
  82. topPadding: 50,
  83. leftPadding: 75,
  84. gridLineStartPadding: 35,
  85. fontSize: 11,
  86. numberSectionStyles: 4,
  87. axisFormat: '%Y-%m-%d',
  88. },
  89. mindmap: {
  90. useMaxWidth: true,
  91. padding: 10,
  92. },
  93. maxTextSize: 50000,
  94. }
  95. mermaid.initialize(config)
  96. isMermaidInitialized = true
  97. }
  98. catch (error) {
  99. console.error('Mermaid initialization error:', error)
  100. return null
  101. }
  102. }
  103. return isMermaidInitialized
  104. }
  105. const Flowchart = React.forwardRef((props: {
  106. PrimitiveCode: string
  107. theme?: 'light' | 'dark'
  108. }, ref) => {
  109. const { t } = useTranslation()
  110. const [svgString, setSvgString] = useState<string | null>(null)
  111. const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
  112. const [isInitialized, setIsInitialized] = useState(false)
  113. const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
  114. const containerRef = useRef<HTMLDivElement>(null)
  115. const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
  116. const [isLoading, setIsLoading] = useState(true)
  117. const renderTimeoutRef = useRef<NodeJS.Timeout>()
  118. const [errMsg, setErrMsg] = useState('')
  119. const [imagePreviewUrl, setImagePreviewUrl] = useState('')
  120. const [isCodeComplete, setIsCodeComplete] = useState(false)
  121. const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
  122. const prevCodeRef = useRef<string>()
  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. // On any render error, assume the mermaid state is corrupted and force a re-initialization.
  164. try {
  165. diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs
  166. isMermaidInitialized = false // <-- THE FIX: Force re-initialization
  167. initMermaid() // Re-initialize with the default safe configuration
  168. }
  169. catch (reinitError) {
  170. console.error('Failed to re-initialize Mermaid after error:', reinitError)
  171. }
  172. setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`)
  173. setIsLoading(false)
  174. }
  175. // Initialize mermaid
  176. useEffect(() => {
  177. const api = initMermaid()
  178. if (api)
  179. setIsInitialized(true)
  180. }, [])
  181. // Update theme when prop changes, but allow internal override.
  182. const prevThemeRef = useRef<string>()
  183. useEffect(() => {
  184. // Only react if the theme prop from the outside has actually changed.
  185. if (props.theme && props.theme !== prevThemeRef.current) {
  186. // When the global theme prop changes, it should act as the source of truth,
  187. // overriding any local theme selection.
  188. diagramCache.clear()
  189. setSvgString(null)
  190. setCurrentTheme(props.theme)
  191. // Reset look to classic for a consistent state after a global change.
  192. setLook('classic')
  193. }
  194. // Update the ref to the current prop value for the next render.
  195. prevThemeRef.current = props.theme
  196. }, [props.theme])
  197. const renderFlowchart = useCallback(async (primitiveCode: string) => {
  198. if (!isInitialized || !containerRef.current) {
  199. setIsLoading(false)
  200. setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found')
  201. return
  202. }
  203. // Return cached result if available
  204. const cacheKey = `${primitiveCode}-${look}-${currentTheme}`
  205. if (diagramCache.has(cacheKey)) {
  206. setErrMsg('')
  207. setSvgString(diagramCache.get(cacheKey) || null)
  208. setIsLoading(false)
  209. return
  210. }
  211. setIsLoading(true)
  212. setErrMsg('')
  213. try {
  214. let finalCode: string
  215. const trimmedCode = primitiveCode.trim()
  216. const isGantt = trimmedCode.startsWith('gantt')
  217. const isMindMap = trimmedCode.startsWith('mindmap')
  218. const isSequence = trimmedCode.startsWith('sequenceDiagram')
  219. if (isGantt || isMindMap || isSequence) {
  220. if (isGantt) {
  221. finalCode = trimmedCode
  222. .split('\n')
  223. .map((line) => {
  224. // Gantt charts have specific syntax needs.
  225. const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
  226. if (!taskMatch)
  227. return line // Not a task line, return as is.
  228. const taskName = taskMatch[1].trim()
  229. let paramsStr = taskMatch[2].trim()
  230. // Rule 1: Correct multiple "after" dependencies ONLY if they exist.
  231. // This is a common mistake, e.g., "..., after task1, after task2, ..."
  232. const afterCount = (paramsStr.match(/after /g) || []).length
  233. if (afterCount > 1)
  234. paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ')
  235. // Rule 2: Normalize spacing between parameters for consistency.
  236. const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim()
  237. return `${taskName} :${finalParams}`
  238. })
  239. .join('\n')
  240. }
  241. else {
  242. // For mindmap and sequence charts, which are sensitive to syntax,
  243. // pass the code through directly.
  244. finalCode = trimmedCode
  245. }
  246. }
  247. else {
  248. // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
  249. // This function handles flowcharts appropriately.
  250. finalCode = prepareMermaidCode(primitiveCode, look)
  251. }
  252. // Step 2: Render chart
  253. const svgGraph = await renderMermaidChart(finalCode, look)
  254. // Step 3: Apply theme to SVG using the extracted processSvgForTheme function
  255. const processedSvg = processSvgForTheme(
  256. svgGraph.svg,
  257. currentTheme === Theme.dark,
  258. look === 'handDrawn',
  259. THEMES,
  260. )
  261. // Step 4: Clean up SVG code
  262. const cleanedSvg = cleanUpSvgCode(processedSvg)
  263. if (cleanedSvg && typeof cleanedSvg === 'string') {
  264. diagramCache.set(cacheKey, cleanedSvg)
  265. setSvgString(cleanedSvg)
  266. }
  267. setIsLoading(false)
  268. }
  269. catch (error) {
  270. // Error handling
  271. handleRenderError(error)
  272. }
  273. }, [chartId, isInitialized, look, currentTheme, t])
  274. const configureMermaid = useCallback((primitiveCode: string) => {
  275. if (typeof window !== 'undefined' && isInitialized) {
  276. const themeVars = THEMES[currentTheme]
  277. const config: any = {
  278. startOnLoad: false,
  279. securityLevel: 'loose',
  280. fontFamily: 'sans-serif',
  281. maxTextSize: 50000,
  282. gantt: {
  283. titleTopMargin: 25,
  284. barHeight: 20,
  285. barGap: 4,
  286. topPadding: 50,
  287. leftPadding: 75,
  288. gridLineStartPadding: 35,
  289. fontSize: 11,
  290. numberSectionStyles: 4,
  291. axisFormat: '%Y-%m-%d',
  292. },
  293. mindmap: {
  294. useMaxWidth: true,
  295. padding: 10,
  296. },
  297. }
  298. const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart')
  299. if (look === 'classic') {
  300. config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
  301. if (isFlowchart) {
  302. config.flowchart = {
  303. htmlLabels: true,
  304. useMaxWidth: true,
  305. nodeSpacing: 60,
  306. rankSpacing: 80,
  307. curve: 'linear',
  308. ranker: 'tight-tree',
  309. }
  310. }
  311. if (currentTheme === 'dark') {
  312. config.themeVariables = {
  313. background: themeVars.background,
  314. primaryColor: themeVars.primaryColor,
  315. primaryBorderColor: themeVars.primaryBorderColor,
  316. primaryTextColor: themeVars.primaryTextColor,
  317. secondaryColor: themeVars.secondaryColor,
  318. tertiaryColor: themeVars.tertiaryColor,
  319. }
  320. }
  321. }
  322. else { // look === 'handDrawn'
  323. config.theme = 'default'
  324. config.themeCSS = `
  325. .node rect { fill-opacity: 0.85; }
  326. .edgePath .path { stroke-width: 1.5px; }
  327. .label { font-family: 'sans-serif'; }
  328. .edgeLabel { font-family: 'sans-serif'; }
  329. .cluster rect { rx: 5px; ry: 5px; }
  330. `
  331. config.themeVariables = {
  332. fontSize: '14px',
  333. fontFamily: 'sans-serif',
  334. primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor,
  335. }
  336. if (isFlowchart) {
  337. config.flowchart = {
  338. htmlLabels: true,
  339. useMaxWidth: true,
  340. nodeSpacing: 40,
  341. rankSpacing: 60,
  342. curve: 'basis',
  343. }
  344. }
  345. }
  346. try {
  347. mermaid.initialize(config)
  348. return true
  349. }
  350. catch (error) {
  351. console.error('Config error:', error)
  352. return false
  353. }
  354. }
  355. return false
  356. }, [currentTheme, isInitialized, look])
  357. // This is the main rendering effect.
  358. // It triggers whenever the code, theme, or style changes.
  359. useEffect(() => {
  360. if (!isInitialized)
  361. return
  362. // Don't render if code is too short
  363. if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) {
  364. setIsLoading(false)
  365. setSvgString(null)
  366. return
  367. }
  368. // Use a timeout to handle streaming code and debounce rendering
  369. if (renderTimeoutRef.current)
  370. clearTimeout(renderTimeoutRef.current)
  371. setIsLoading(true)
  372. renderTimeoutRef.current = setTimeout(() => {
  373. // Final validation before rendering
  374. if (!isMermaidCodeComplete(props.PrimitiveCode)) {
  375. setIsLoading(false)
  376. setErrMsg('Diagram code is not complete or invalid.')
  377. return
  378. }
  379. const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
  380. if (diagramCache.has(cacheKey)) {
  381. setErrMsg('')
  382. setSvgString(diagramCache.get(cacheKey) || null)
  383. setIsLoading(false)
  384. return
  385. }
  386. if (configureMermaid(props.PrimitiveCode))
  387. renderFlowchart(props.PrimitiveCode)
  388. }, 300) // 300ms debounce
  389. return () => {
  390. if (renderTimeoutRef.current)
  391. clearTimeout(renderTimeoutRef.current)
  392. }
  393. }, [props.PrimitiveCode, look, currentTheme, isInitialized, configureMermaid, renderFlowchart])
  394. // Cleanup on unmount
  395. useEffect(() => {
  396. return () => {
  397. if (containerRef.current)
  398. containerRef.current.innerHTML = ''
  399. if (renderTimeoutRef.current)
  400. clearTimeout(renderTimeoutRef.current)
  401. }
  402. }, [])
  403. const handlePreviewClick = async () => {
  404. if (svgString) {
  405. const base64 = await svgToBase64(svgString)
  406. setImagePreviewUrl(base64)
  407. }
  408. }
  409. const toggleTheme = () => {
  410. const newTheme = currentTheme === 'light' ? 'dark' : 'light'
  411. // Ensure a full, clean re-render cycle, consistent with global theme change.
  412. diagramCache.clear()
  413. setSvgString(null)
  414. setCurrentTheme(newTheme)
  415. }
  416. // Style classes for theme-dependent elements
  417. const themeClasses = {
  418. container: cn('relative', {
  419. 'bg-white': currentTheme === Theme.light,
  420. 'bg-slate-900': currentTheme === Theme.dark,
  421. }),
  422. mermaidDiv: cn('mermaid relative h-auto w-full cursor-pointer', {
  423. 'bg-white': currentTheme === Theme.light,
  424. 'bg-slate-900': currentTheme === Theme.dark,
  425. }),
  426. errorMessage: cn('px-[26px] py-4', {
  427. 'text-red-500': currentTheme === Theme.light,
  428. 'text-red-400': currentTheme === Theme.dark,
  429. }),
  430. errorIcon: cn('h-6 w-6', {
  431. 'text-red-500': currentTheme === Theme.light,
  432. 'text-red-400': currentTheme === Theme.dark,
  433. }),
  434. segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', {
  435. 'text-gray-700': currentTheme === Theme.light,
  436. 'text-gray-300': currentTheme === Theme.dark,
  437. }),
  438. themeToggle: cn('flex h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', {
  439. 'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
  440. 'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
  441. }),
  442. }
  443. // Style classes for look options
  444. const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
  445. return cn(
  446. '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',
  447. look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
  448. currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
  449. look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
  450. )
  451. }
  452. return (
  453. <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
  454. <div className={themeClasses.segmented}>
  455. <div className="msh-segmented-group">
  456. <label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
  457. <div
  458. key='classic'
  459. className={getLookButtonClass('classic')}
  460. onClick={() => {
  461. if (look !== 'classic') {
  462. diagramCache.clear()
  463. setSvgString(null)
  464. setLook('classic')
  465. }
  466. }}
  467. >
  468. <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
  469. </div>
  470. <div
  471. key='handDrawn'
  472. className={getLookButtonClass('handDrawn')}
  473. onClick={() => {
  474. if (look !== 'handDrawn') {
  475. diagramCache.clear()
  476. setSvgString(null)
  477. setLook('handDrawn')
  478. }
  479. }}
  480. >
  481. <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
  482. </div>
  483. </label>
  484. </div>
  485. </div>
  486. <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
  487. {isLoading && !svgString && (
  488. <div className='px-[26px] py-4'>
  489. <LoadingAnim type='text'/>
  490. {!isCodeComplete && (
  491. <div className="mt-2 text-sm text-gray-500">
  492. {t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
  493. </div>
  494. )}
  495. </div>
  496. )}
  497. {svgString && (
  498. <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={handlePreviewClick}>
  499. <div className="absolute bottom-2 left-2 z-[100]">
  500. <button
  501. onClick={(e) => {
  502. e.stopPropagation()
  503. toggleTheme()
  504. }}
  505. className={themeClasses.themeToggle}
  506. title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
  507. style={{ transform: 'translate3d(0, 0, 0)' }}
  508. >
  509. {currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
  510. </button>
  511. </div>
  512. <div
  513. style={{ maxWidth: '100%' }}
  514. dangerouslySetInnerHTML={{ __html: svgString }}
  515. />
  516. </div>
  517. )}
  518. {errMsg && (
  519. <div className={themeClasses.errorMessage}>
  520. <div className="flex items-center">
  521. <ExclamationTriangleIcon className={themeClasses.errorIcon}/>
  522. <span className="ml-2">{errMsg}</span>
  523. </div>
  524. </div>
  525. )}
  526. {imagePreviewUrl && (
  527. <ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
  528. )}
  529. </div>
  530. )
  531. })
  532. Flowchart.displayName = 'Flowchart'
  533. export default Flowchart