fix(markdown): improve ECharts rendering for streaming content and da… (#20101)

Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
sayThQ199 2025-05-22 16:31:13 +08:00 committed by GitHub
parent 8fad3036bf
commit 7bf00ef25c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -11,7 +11,7 @@ import {
atelierHeathDark,
atelierHeathLight,
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
import { Component, memo, useMemo, useRef, useState } from 'react'
import { Component, memo, useEffect, useMemo, useRef, useState } from 'react'
import { flow } from 'lodash-es'
import ActionButton from '@/app/components/base/action-button'
import CopyIcon from '@/app/components/base/copy-icon'
@ -74,7 +74,7 @@ const preprocessLaTeX = (content: string) => {
processedContent = flow([
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\[(.*?)\\\]/gs, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
(str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
])(processedContent)
@ -124,23 +124,143 @@ export function PreCode(props: { children: any }) {
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
const { theme } = useTheme()
const [isSVG, setIsSVG] = useState(true)
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading')
const [finalChartOption, setFinalChartOption] = useState<any>(null)
const echartsRef = useRef<any>(null)
const contentRef = useRef<string>('')
const processedRef = useRef<boolean>(false) // Track if content was successfully processed
const match = /language-(\w+)/.exec(className || '')
const language = match?.[1]
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
const chartData = useMemo(() => {
const str = String(children).replace(/\n$/, '')
if (language === 'echarts') {
try {
return JSON.parse(str)
}
catch { }
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
return new Function(`return ${str}`)()
}
catch { }
const isDarkMode = theme === Theme.dark
// Handle container resize for echarts
useEffect(() => {
if (language !== 'echarts' || !echartsRef.current) return
const handleResize = () => {
// This gets the echarts instance from the component
const instance = echartsRef.current?.getEchartsInstance?.()
if (instance)
instance.resize()
}
window.addEventListener('resize', handleResize)
// Also manually trigger resize after a short delay to ensure proper sizing
const resizeTimer = setTimeout(handleResize, 200)
return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(resizeTimer)
}
}, [language, echartsRef.current])
// Process chart data when content changes
useEffect(() => {
// Only process echarts content
if (language !== 'echarts') return
// Reset state when new content is detected
if (!contentRef.current) {
setChartState('loading')
processedRef.current = false
}
const newContent = String(children).replace(/\n$/, '')
// Skip if content hasn't changed
if (contentRef.current === newContent) return
contentRef.current = newContent
const trimmedContent = newContent.trim()
if (!trimmedContent) return
// Detect if this is historical data (already complete)
// Historical data typically comes as a complete code block with complete JSON
const isCompleteJson
= (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')
&& trimmedContent.split('{').length === trimmedContent.split('}').length)
|| (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')
&& trimmedContent.split('[').length === trimmedContent.split(']').length)
// If the JSON structure looks complete, try to parse it right away
if (isCompleteJson && !processedRef.current) {
try {
const parsed = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null) {
setFinalChartOption(parsed)
setChartState('success')
processedRef.current = true
return
}
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
setChartState('success')
processedRef.current = true
return
}
}
catch {
// If we have a complete JSON structure but it doesn't parse,
// it's likely an error rather than incomplete data
setChartState('error')
processedRef.current = true
return
}
}
}
// If we get here, either the JSON isn't complete yet, or we failed to parse it
// Check more conditions for streaming data
const isIncomplete
= trimmedContent.length < 5
|| (trimmedContent.startsWith('{')
&& (!trimmedContent.endsWith('}')
|| trimmedContent.split('{').length !== trimmedContent.split('}').length))
|| (trimmedContent.startsWith('[')
&& (!trimmedContent.endsWith(']')
|| trimmedContent.split('[').length !== trimmedContent.split('}').length))
|| (trimmedContent.split('"').length % 2 !== 1)
|| (trimmedContent.includes('{"') && !trimmedContent.includes('"}'))
// Only try to parse streaming data if it looks complete and hasn't been processed
if (!isIncomplete && !processedRef.current) {
let isValidOption = false
try {
const parsed = JSON.parse(trimmedContent)
if (typeof parsed === 'object' && parsed !== null) {
setFinalChartOption(parsed)
isValidOption = true
}
}
catch {
try {
// eslint-disable-next-line no-new-func, sonarjs/code-eval
const result = new Function(`return ${trimmedContent}`)()
if (typeof result === 'object' && result !== null) {
setFinalChartOption(result)
isValidOption = true
}
}
catch {
// Both parsing methods failed, but content looks complete
setChartState('error')
processedRef.current = true
}
}
if (isValidOption) {
setChartState('success')
processedRef.current = true
}
}
return JSON.parse('{"title":{"text":"ECharts error - Wrong option."}}')
}, [language, children])
const renderCodeContent = useMemo(() => {
@ -150,14 +270,125 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
if (isSVG)
return <Flowchart PrimitiveCode={content} />
break
case 'echarts':
case 'echarts': {
// Loading state: show loading indicator
if (chartState === 'loading') {
return (
<div style={{
minHeight: '350px',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: isDarkMode ? 'var(--color-components-input-bg-normal)' : 'transparent',
color: 'var(--color-text-secondary)',
}}>
<div style={{
marginBottom: '12px',
width: '24px',
height: '24px',
}}>
{/* Rotating spinner that works in both light and dark modes */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ animation: 'spin 1.5s linear infinite' }}>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
<circle opacity="0.2" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<div style={{
fontFamily: 'var(--font-family)',
fontSize: '14px',
}}>Chart loading...</div>
</div>
)
}
// Success state: show the chart
if (chartState === 'success' && finalChartOption) {
return (
<div style={{
minWidth: '300px',
minHeight: '350px',
width: '100%',
overflowX: 'auto',
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
transition: 'background-color 0.3s ease',
}}>
<ErrorBoundary>
<ReactEcharts
ref={echartsRef}
option={finalChartOption}
style={{
height: '350px',
width: '100%',
}}
theme={isDarkMode ? 'dark' : undefined}
opts={{
renderer: 'canvas',
width: 'auto',
}}
notMerge={true}
onEvents={{
// Force resize when chart is finished rendering
finished: () => {
const instance = echartsRef.current?.getEchartsInstance?.()
if (instance)
instance.resize()
},
}}
/>
</ErrorBoundary>
</div>
)
}
// Error state: show error message
const errorOption = {
title: {
text: 'ECharts error - Wrong option.',
},
}
return (
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
<div style={{
minWidth: '300px',
minHeight: '350px',
width: '100%',
overflowX: 'auto',
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
transition: 'background-color 0.3s ease',
}}>
<ErrorBoundary>
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
<ReactEcharts
ref={echartsRef}
option={errorOption}
style={{
height: '350px',
width: '100%',
}}
theme={isDarkMode ? 'dark' : undefined}
opts={{
renderer: 'canvas',
width: 'auto',
}}
notMerge={true}
/>
</ErrorBoundary>
</div>
)
}
case 'svg':
if (isSVG) {
return (
@ -192,7 +423,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
</SyntaxHighlighter>
)
}
}, [children, language, isSVG, chartData, props, theme, match])
}, [children, language, isSVG, finalChartOption, props, theme, match])
if (inline || !match)
return <code {...props} className={className}>{children}</code>