// src/components/ChatBot/MarkdownWithCharts.js // 支持 ECharts 图表的 Markdown 渲染组件 import React, { useMemo } from 'react'; import { Box, Alert, AlertIcon, Text, VStack, Code, useColorModeValue, Table, Thead, Tbody, Tr, Th, Td, TableContainer } from '@chakra-ui/react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { EChartsRenderer } from './EChartsRenderer'; import { logger } from '@utils/logger'; /** * 稳定的图表组件包装器 * 使用 useMemo 避免 option 对象引用变化导致的重复渲染 */ const StableChart = React.memo(({ jsonString, height, variant }) => { const chartOption = useMemo(() => { try { return JSON.parse(jsonString); } catch (e) { console.error('[StableChart] JSON parse error:', e); return null; } }, [jsonString]); if (!chartOption) { return ( 图表配置解析失败 ); } return ; }); /** * 解析 Markdown 内容,提取 ECharts 代码块 * 支持处理: * 1. 正常的换行符 \n * 2. 转义的换行符 \\n(后端 JSON 序列化产生) * 3. 不完整的代码块(LLM 输出被截断) * * @param {string} markdown - Markdown 文本 * @returns {Array} - 包含文本和图表的数组 */ const parseMarkdownWithCharts = (markdown) => { if (!markdown) return []; let content = markdown; // 处理转义的换行符(后端返回的 JSON 字符串可能包含 \\n) // 只处理代码块标记周围的换行符,不破坏 JSON 内部结构 // 将 ```echarts\\n 转换为 ```echarts\n content = content.replace(/```echarts\\n/g, '```echarts\n'); // 将 \\n``` 转换为 \n``` content = content.replace(/\\n```/g, '\n```'); // 如果整个内容都是转义的换行符格式,进行全局替换 // 检测:如果内容中没有真正的换行符但有 \\n,则进行全局替换 if (!content.includes('\n') && content.includes('\\n')) { content = content.replace(/\\n/g, '\n'); } const parts = []; // 匹配 echarts 代码块的正则表达式 // 支持多种格式: // 1. ```echarts\n{...}\n``` // 2. ```echarts\n{...}```(末尾无换行) // 3. ```echarts {...}```(同一行开始,虽不推荐但兼容) const echartsBlockRegex = /```echarts\s*\n?([\s\S]*?)```/g; let lastIndex = 0; let match; // 匹配所有 echarts 代码块 while ((match = echartsBlockRegex.exec(content)) !== null) { // 添加代码块前的文本 if (match.index > lastIndex) { const textBefore = content.substring(lastIndex, match.index).trim(); if (textBefore) { parts.push({ type: 'text', content: textBefore }); } } // 提取 ECharts 配置内容 let chartConfig = match[1].trim(); // 处理 JSON 内部的转义换行符(恢复为真正的换行,便于后续解析) // 注意:这里的 \\n 在 JSON 内部应该保持为 \n(换行符),不是字面量 if (chartConfig.includes('\\n')) { chartConfig = chartConfig.replace(/\\n/g, '\n'); } if (chartConfig.includes('\\t')) { chartConfig = chartConfig.replace(/\\t/g, '\t'); } if (chartConfig) { parts.push({ type: 'chart', content: chartConfig }); } lastIndex = match.index + match[0].length; } // 检查剩余内容 if (lastIndex < content.length) { const remainingText = content.substring(lastIndex); // 检查是否有不完整的 echarts 代码块(没有结束的 ```) const incompleteMatch = remainingText.match(/```echarts\s*\n?([\s\S]*?)$/); if (incompleteMatch) { // 提取不完整代码块之前的文本 const textBeforeIncomplete = remainingText.substring(0, incompleteMatch.index).trim(); if (textBeforeIncomplete) { parts.push({ type: 'text', content: textBeforeIncomplete }); } // 提取不完整的 echarts 内容 let incompleteChartConfig = incompleteMatch[1].trim(); // 同样处理转义换行符 if (incompleteChartConfig.includes('\\n')) { incompleteChartConfig = incompleteChartConfig.replace(/\\n/g, '\n'); } if (incompleteChartConfig) { logger.warn('[MarkdownWithCharts] 检测到不完整的 echarts 代码块', { contentPreview: incompleteChartConfig.substring(0, 100), }); parts.push({ type: 'chart', content: incompleteChartConfig }); } } else { // 普通剩余文本 const textAfter = remainingText.trim(); if (textAfter) { parts.push({ type: 'text', content: textAfter }); } } } // 如果没有找到任何部分,返回整个 markdown 作为文本 if (parts.length === 0) { parts.push({ type: 'text', content: content }); } // 开发环境调试 if (process.env.NODE_ENV === 'development') { const chartParts = parts.filter(p => p.type === 'chart'); if (chartParts.length > 0 || content.includes('echarts')) { logger.info('[MarkdownWithCharts] 解析结果', { inputLength: markdown?.length, hasEchartsKeyword: content.includes('echarts'), hasCodeBlock: content.includes('```'), partsCount: parts.length, partTypes: parts.map(p => p.type), }); } } return parts; }; /** * 支持 ECharts 图表的 Markdown 渲染组件 * @param {string} content - Markdown 文本 * @param {string} variant - 主题变体: 'light' | 'dark' | 'auto' (默认 auto,跟随系统) */ export const MarkdownWithCharts = ({ content, variant = 'auto' }) => { const parts = parseMarkdownWithCharts(content); // 系统颜色模式 const systemTextColor = useColorModeValue('gray.700', 'gray.100'); const systemHeadingColor = useColorModeValue('gray.800', 'gray.50'); const systemBlockquoteColor = useColorModeValue('gray.600', 'gray.300'); const systemCodeBg = useColorModeValue('gray.100', 'rgba(255, 255, 255, 0.1)'); // 根据 variant 选择颜色 const isDark = variant === 'dark'; const isLight = variant === 'light'; const textColor = isDark ? 'gray.100' : isLight ? 'gray.700' : systemTextColor; const headingColor = isDark ? 'gray.50' : isLight ? 'gray.800' : systemHeadingColor; const blockquoteColor = isDark ? 'gray.300' : isLight ? 'gray.600' : systemBlockquoteColor; const codeBg = isDark ? 'rgba(255, 255, 255, 0.1)' : isLight ? 'gray.100' : systemCodeBg; return ( {parts.map((part, index) => { if (part.type === 'text') { // 渲染普通 Markdown return ( ( {children} ), h1: ({ children }) => ( {children} ), h2: ({ children }) => ( {children} ), h3: ({ children }) => ( {children} ), ul: ({ children }) => ( {children} ), ol: ({ children }) => ( {children} ), li: ({ children }) => ( {children} ), // 处理代码块和行内代码 code: ({ node, inline, className, children, ...props }) => { // 检查是否是代码块(通过父元素是否为 pre 或通过 className 判断) const isCodeBlock = !inline && (className || (node?.position?.start?.line !== node?.position?.end?.line)); if (isCodeBlock) { // 代码块样式 return ( {children} ); } // 行内代码样式 return ( {children} ); }, // 处理 pre 元素,防止嵌套问题 pre: ({ children }) => ( {children} ), blockquote: ({ children }) => ( {children} ), // 表格渲染 table: ({ children }) => ( {children}
), thead: ({ children }) => ( {children} ), tbody: ({ children }) => {children}, tr: ({ children }) => ( {children} ), th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), }} > {part.content}
); } else if (part.type === 'chart') { // 渲染 ECharts 图表 // 清理可能的残留字符 let cleanContent = part.content.trim(); cleanContent = cleanContent.replace(/```\s*$/g, '').trim(); // 调试日志 console.log('[MarkdownWithCharts] Rendering chart, content length:', cleanContent.length); console.log('[MarkdownWithCharts] Content preview:', cleanContent.substring(0, 100)); // 验证 JSON 是否可以解析 try { const testParse = JSON.parse(cleanContent); console.log('[MarkdownWithCharts] JSON valid, has series:', !!testParse.series); } catch (e) { console.error('[MarkdownWithCharts] JSON parse error:', e.message); console.log('[MarkdownWithCharts] Problematic content:', cleanContent.substring(0, 300)); return ( 图表配置解析失败 错误: {e.message} {cleanContent.substring(0, 300)} {cleanContent.length > 300 ? '...' : ''} ); } return ( ); } return null; })}
); };