307 lines
11 KiB
JavaScript
307 lines
11 KiB
JavaScript
// src/components/ChatBot/MarkdownWithCharts.js
|
||
// 支持 ECharts 图表的 Markdown 渲染组件
|
||
|
||
import React 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';
|
||
|
||
/**
|
||
* 清理消息中可能存在的残缺 JSON 片段
|
||
* 这种情况通常是模型生成时意外混入的工具返回数据
|
||
* @param {string} text - 原始文本
|
||
* @returns {string} - 清理后的文本
|
||
*/
|
||
const cleanBrokenJson = (text) => {
|
||
if (!text) return text;
|
||
|
||
// 移除可能的残缺 JSON 对象片段(没有开头的 { 但有结尾的 })
|
||
// 例如: "...一些文字itemStyle": {"color": "#ee6666"}, "smooth": true}]\n}"
|
||
let cleaned = text;
|
||
|
||
// 模式1: 移除以 JSON 属性开始的残缺片段 (如 itemStyle": {...})
|
||
cleaned = cleaned.replace(/[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*\{[^{}]*\}(\s*,\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*[^,}\]]+)*\s*\}\s*\]\s*\}/g, '');
|
||
|
||
// 模式2: 移除孤立的 JSON 数组/对象结尾 (如 ]\n})
|
||
cleaned = cleaned.replace(/\s*\]\s*\}\s*```\s*$/g, '');
|
||
|
||
// 模式3: 移除不完整的 echarts 代码块残留
|
||
// 匹配没有开始标记的残缺内容
|
||
cleaned = cleaned.replace(/[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*\[[^\]]*\]\s*\}\s*```/g, '');
|
||
|
||
// 模式4: 清理残留的属性片段
|
||
cleaned = cleaned.replace(/,?\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*(?:true|false|null|\d+|"[^"]*")\s*\}\s*\]\s*\}\s*$/g, '');
|
||
|
||
return cleaned.trim();
|
||
};
|
||
|
||
/**
|
||
* 解析 Markdown 内容,提取 ECharts 代码块
|
||
* @param {string} markdown - Markdown 文本
|
||
* @returns {Array} - 包含文本和图表的数组
|
||
*/
|
||
const parseMarkdownWithCharts = (markdown) => {
|
||
if (!markdown) return [];
|
||
|
||
// 先清理可能的残缺 JSON
|
||
const cleanedMarkdown = cleanBrokenJson(markdown);
|
||
|
||
const parts = [];
|
||
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
|
||
let lastIndex = 0;
|
||
let match;
|
||
|
||
while ((match = echartsRegex.exec(cleanedMarkdown)) !== null) {
|
||
// 添加代码块前的文本
|
||
if (match.index > lastIndex) {
|
||
const textBefore = cleanedMarkdown.substring(lastIndex, match.index).trim();
|
||
if (textBefore) {
|
||
parts.push({ type: 'text', content: textBefore });
|
||
}
|
||
}
|
||
|
||
// 添加 ECharts 配置
|
||
const chartConfig = match[1].trim();
|
||
parts.push({ type: 'chart', content: chartConfig });
|
||
|
||
lastIndex = match.index + match[0].length;
|
||
}
|
||
|
||
// 添加剩余文本
|
||
if (lastIndex < cleanedMarkdown.length) {
|
||
const textAfter = cleanedMarkdown.substring(lastIndex).trim();
|
||
if (textAfter) {
|
||
parts.push({ type: 'text', content: textAfter });
|
||
}
|
||
}
|
||
|
||
// 如果没有找到图表,返回整个 markdown 作为文本
|
||
if (parts.length === 0) {
|
||
parts.push({ type: 'text', content: cleanedMarkdown });
|
||
}
|
||
|
||
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: ({ inline, children }) =>
|
||
inline ? (
|
||
<Code fontSize="sm" px={1} bg={codeBg}>
|
||
{children}
|
||
</Code>
|
||
) : (
|
||
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap" bg={codeBg}>
|
||
{children}
|
||
</Code>
|
||
),
|
||
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 图表
|
||
try {
|
||
// 清理可能的 Markdown 残留符号
|
||
let cleanContent = part.content.trim();
|
||
|
||
// 移除可能的前后空白和不可见字符
|
||
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
|
||
|
||
// 尝试解析 JSON
|
||
const chartOption = JSON.parse(cleanContent);
|
||
|
||
// 验证是否是有效的 ECharts 配置
|
||
if (!chartOption || typeof chartOption !== 'object') {
|
||
throw new Error('Invalid chart configuration: not an object');
|
||
}
|
||
|
||
// 验证 series 是否存在且有效
|
||
if (!chartOption.series) {
|
||
throw new Error('Invalid chart configuration: missing series');
|
||
}
|
||
const series = Array.isArray(chartOption.series) ? chartOption.series : [chartOption.series];
|
||
const hasValidSeries = series.some(s => s && s.data && Array.isArray(s.data) && s.data.length > 0);
|
||
if (!hasValidSeries) {
|
||
throw new Error('Invalid chart configuration: series has no valid data');
|
||
}
|
||
|
||
// 验证 xAxis(如果存在)
|
||
if (chartOption.xAxis) {
|
||
const xAxis = Array.isArray(chartOption.xAxis) ? chartOption.xAxis[0] : chartOption.xAxis;
|
||
if (xAxis && xAxis.type === 'category' && (!xAxis.data || !Array.isArray(xAxis.data) || xAxis.data.length === 0)) {
|
||
throw new Error('Invalid chart configuration: xAxis category type requires data');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Box key={index}>
|
||
<EChartsRenderer option={chartOption} height={350} variant={variant} />
|
||
</Box>
|
||
);
|
||
} catch (error) {
|
||
// 记录详细的错误信息
|
||
logger.error('解析 ECharts 配置失败', {
|
||
error: error.message,
|
||
contentLength: part.content.length,
|
||
contentPreview: part.content.substring(0, 200),
|
||
errorStack: error.stack
|
||
});
|
||
|
||
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">
|
||
错误: {error.message}
|
||
</Text>
|
||
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
|
||
{part.content.substring(0, 300)}
|
||
{part.content.length > 300 ? '...' : ''}
|
||
</Code>
|
||
</VStack>
|
||
</Alert>
|
||
);
|
||
}
|
||
}
|
||
return null;
|
||
})}
|
||
</VStack>
|
||
);
|
||
};
|