391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
// 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 (
|
||
<Alert status="warning" borderRadius="md">
|
||
<AlertIcon />
|
||
<Text fontSize="sm">图表配置解析失败</Text>
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
return <EChartsRenderer option={chartOption} height={height} variant={variant} />;
|
||
});
|
||
|
||
/**
|
||
* 解析 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 (
|
||
<VStack align="stretch" spacing={4}>
|
||
{parts.map((part, index) => {
|
||
if (part.type === 'text') {
|
||
// 渲染普通 Markdown
|
||
return (
|
||
<Box key={index}>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
components={{
|
||
// 自定义渲染样式
|
||
p: ({ children }) => (
|
||
<Text mb={2} fontSize="sm" color={textColor}>
|
||
{children}
|
||
</Text>
|
||
),
|
||
h1: ({ children }) => (
|
||
<Text fontSize="xl" fontWeight="bold" mb={3} color={headingColor}>
|
||
{children}
|
||
</Text>
|
||
),
|
||
h2: ({ children }) => (
|
||
<Text fontSize="lg" fontWeight="bold" mb={2} color={headingColor}>
|
||
{children}
|
||
</Text>
|
||
),
|
||
h3: ({ children }) => (
|
||
<Text fontSize="md" fontWeight="bold" mb={2} color={headingColor}>
|
||
{children}
|
||
</Text>
|
||
),
|
||
ul: ({ children }) => (
|
||
<Box as="ul" pl={4} mb={2}>
|
||
{children}
|
||
</Box>
|
||
),
|
||
ol: ({ children }) => (
|
||
<Box as="ol" pl={4} mb={2}>
|
||
{children}
|
||
</Box>
|
||
),
|
||
li: ({ children }) => (
|
||
<Box as="li" fontSize="sm" mb={1} color={textColor}>
|
||
{children}
|
||
</Box>
|
||
),
|
||
// 处理代码块和行内代码
|
||
code: ({ node, inline, className, children, ...props }) => {
|
||
// 检查是否是代码块(通过父元素是否为 pre 或通过 className 判断)
|
||
const isCodeBlock = !inline && (className || (node?.position?.start?.line !== node?.position?.end?.line));
|
||
|
||
if (isCodeBlock) {
|
||
// 代码块样式
|
||
return (
|
||
<Code
|
||
display="block"
|
||
p={3}
|
||
borderRadius="md"
|
||
fontSize="sm"
|
||
whiteSpace="pre-wrap"
|
||
bg={codeBg}
|
||
overflowX="auto"
|
||
maxW="100%"
|
||
{...props}
|
||
>
|
||
{children}
|
||
</Code>
|
||
);
|
||
}
|
||
// 行内代码样式
|
||
return (
|
||
<Code fontSize="sm" px={1} bg={codeBg} {...props}>
|
||
{children}
|
||
</Code>
|
||
);
|
||
},
|
||
// 处理 pre 元素,防止嵌套问题
|
||
pre: ({ children }) => (
|
||
<Box as="pre" my={2} overflow="hidden" borderRadius="md">
|
||
{children}
|
||
</Box>
|
||
),
|
||
blockquote: ({ children }) => (
|
||
<Box
|
||
borderLeftWidth="4px"
|
||
borderLeftColor="blue.500"
|
||
pl={4}
|
||
py={2}
|
||
fontStyle="italic"
|
||
color={blockquoteColor}
|
||
>
|
||
{children}
|
||
</Box>
|
||
),
|
||
// 表格渲染
|
||
table: ({ children }) => (
|
||
<TableContainer
|
||
mb={4}
|
||
borderRadius="md"
|
||
border="1px solid"
|
||
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
|
||
overflowX="auto"
|
||
>
|
||
<Table size="sm" variant="simple">
|
||
{children}
|
||
</Table>
|
||
</TableContainer>
|
||
),
|
||
thead: ({ children }) => (
|
||
<Thead bg={isDark ? 'rgba(255, 255, 255, 0.05)' : 'gray.50'}>
|
||
{children}
|
||
</Thead>
|
||
),
|
||
tbody: ({ children }) => <Tbody>{children}</Tbody>,
|
||
tr: ({ children }) => (
|
||
<Tr
|
||
_hover={{
|
||
bg: isDark ? 'rgba(255, 255, 255, 0.03)' : 'gray.50'
|
||
}}
|
||
>
|
||
{children}
|
||
</Tr>
|
||
),
|
||
th: ({ children }) => (
|
||
<Th
|
||
fontSize="xs"
|
||
color={headingColor}
|
||
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
|
||
py={2}
|
||
>
|
||
{children}
|
||
</Th>
|
||
),
|
||
td: ({ children }) => (
|
||
<Td
|
||
fontSize="sm"
|
||
color={textColor}
|
||
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
|
||
py={2}
|
||
>
|
||
{children}
|
||
</Td>
|
||
),
|
||
}}
|
||
>
|
||
{part.content}
|
||
</ReactMarkdown>
|
||
</Box>
|
||
);
|
||
} 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 (
|
||
<Alert status="warning" key={index} borderRadius="md">
|
||
<AlertIcon />
|
||
<VStack align="flex-start" spacing={1} flex="1">
|
||
<Text fontSize="sm" fontWeight="bold">
|
||
图表配置解析失败
|
||
</Text>
|
||
<Text fontSize="xs" color="gray.600">
|
||
错误: {e.message}
|
||
</Text>
|
||
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
|
||
{cleanContent.substring(0, 300)}
|
||
{cleanContent.length > 300 ? '...' : ''}
|
||
</Code>
|
||
</VStack>
|
||
</Alert>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box
|
||
key={index}
|
||
w="100%"
|
||
minW="300px"
|
||
my={3}
|
||
borderRadius="md"
|
||
overflow="hidden"
|
||
>
|
||
<StableChart jsonString={cleanContent} height={350} variant={variant} />
|
||
</Box>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</VStack>
|
||
);
|
||
};
|