Files
vf_react/src/components/ChatBot/MarkdownWithCharts.js
2025-11-30 18:55:35 +08:00

391 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
};