feat: add new Markdown components and enhance i18n support (#5622)

* feat: add new Markdown components and enhance i18n support

(cherry picked from commit b0b6cc7ad49ac35f070389744a764928d7103074)

* feat: support structured data render

---------

Co-authored-by: FinleyGe <m13203533462@163.com>
This commit is contained in:
Ctrlz
2025-09-23 21:13:56 +08:00
committed by GitHub
parent 9f8f8dd3de
commit 7be8ece638
15 changed files with 948 additions and 53 deletions

View File

@@ -29,6 +29,7 @@
"axios": "^1.12.1",
"date-fns": "2.30.0",
"dayjs": "^1.11.7",
"dompurify": "^3.2.7",
"echarts": "5.4.1",
"echarts-gl": "2.0.9",
"framer-motion": "9.1.7",
@@ -58,6 +59,7 @@
"recharts": "^2.15.0",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.0",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
@@ -68,6 +70,7 @@
},
"devDependencies": {
"@svgr/webpack": "^6.5.1",
"@types/dompurify": "^3.2.0",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.3",
"@types/lodash": "^4.14.191",

View File

@@ -23,7 +23,6 @@ import Markdown from '.';
import { getSourceNameIcon } from '@fastgpt/global/core/dataset/utils';
import { Types } from 'mongoose';
import type { OutLinkChatAuthProps } from '@fastgpt/global/support/permission/chat';
import { useCreation } from 'ahooks';
export type AProps = {
chatAuthData?: {

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { Box } from '@chakra-ui/react';
const Divider: React.FC = () => {
return <Box width="100%" height="1px" bg="gray.200" my={4} mx="auto" />;
};
export default Divider;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Box, Text, VStack, Flex } from '@chakra-ui/react';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
type IndicatorCardProps = {
dataList: {
name: string;
value: string | number;
}[];
};
const IndicatorCard: React.FC<IndicatorCardProps> = ({ dataList }) => {
const { t } = useSafeTranslation();
if (!dataList || !Array.isArray(dataList) || dataList.length === 0) {
return <Box>No indicator data available</Box>;
}
return (
<VStack align="stretch">
{dataList.map((indicator, index) => (
<Flex align="stretch" w="250px" key={index} gap={1} mt={1}>
<Flex w="5px" bg="blue.500"></Flex>
<Flex
flex="1"
borderRadius="md"
bg="gray.100"
display="flex"
flexDirection="column"
overflow="hidden"
>
{/* indicator name */}
<Flex w="full">
<Text
color="gray.800"
fontSize="sm"
fontWeight="normal"
textAlign="right"
flex="1"
noOfLines={1}
>
{indicator.name}
</Text>
</Flex>
{/* indicator value and unit */}
<Flex w="full">
<Text
color="blue.500"
fontSize="lg"
fontWeight="bold"
textAlign="right"
flex="1"
noOfLines={1}
>
{indicator.value || t('common:core.chat.indicator.no_data')}
</Text>
</Flex>
</Flex>
</Flex>
))}
</VStack>
);
};
export default IndicatorCard;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Box, Text, Link, Flex } from '@chakra-ui/react';
const LinkBlock: React.FC<{ data: { text: string; url: string } }> = ({ data }) => {
const handleClick = () => {
window.open(data.url, '_blank', 'noopener,noreferrer');
};
return (
<Box my={4}>
<Link
onClick={handleClick}
cursor="pointer"
textDecoration="none"
_hover={{ textDecoration: 'none' }}
>
<Text
fontWeight="medium"
color="blue.600"
_hover={{ color: 'blue.700' }}
noOfLines={1}
flex="1"
>
{data.text}
</Text>
</Link>
</Box>
);
};
export default LinkBlock;

View File

@@ -0,0 +1,142 @@
import React, { useState, useMemo } from 'react';
import { Box, Flex, Text, Grid } from '@chakra-ui/react';
import Icon from '@fastgpt/web/components/common/Icon';
import MySelect from '@fastgpt/web/components/common/MySelect';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
const TableBlock: React.FC<{ code: string }> = ({ code }) => {
const { t } = useSafeTranslation();
const tableData = JSON.parse(code);
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const headers = Object.keys(tableData[0]);
// calculate paginated data
const { paginatedData, totalPages } = useMemo(() => {
const total = Math.ceil(tableData.length / perPage);
const startIndex = (currentPage - 1) * perPage;
const endIndex = startIndex + perPage;
const paginated = tableData.slice(startIndex, endIndex);
return {
paginatedData: paginated,
totalPages: total
};
}, [tableData, currentPage, perPage]);
const handlePrevPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
};
const handleNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages));
};
const handlePerPageChange = (value: string) => {
setPerPage(Number(value));
setCurrentPage(1); // reset to first page
};
return (
<Box my={4}>
<Flex overflowX="auto">
<table style={{ width: '100%', borderCollapse: 'collapse', border: '1px solid #e2e8f0' }}>
<thead>
<tr style={{ backgroundColor: '#f7fafc' }}>
{headers.map((header, index) => (
<th
key={index}
style={{
padding: '12px',
border: '1px solid #e2e8f0',
textAlign: 'left',
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row: any, rowIndex: number) => (
<tr
key={rowIndex}
style={{ backgroundColor: rowIndex % 2 === 0 ? '#ffffff' : '#f9f9f9' }}
>
{headers.map((header, colIndex) => (
<td
key={colIndex}
style={{
padding: '12px',
border: '1px solid #e2e8f0',
whiteSpace: 'nowrap'
}}
>
{row[header] || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</Flex>
<Grid width="full" gridTemplateColumns="1fr auto 1fr" alignItems="center" gap={4}>
<Flex gap={1} align="center" gridColumn="2">
<Icon
name="core/chat/chevronLeft"
w="16px"
height="16px"
cursor={currentPage === 1 ? 'not-allowed' : 'pointer'}
opacity={currentPage === 1 ? 0.5 : 1}
onClick={currentPage === 1 ? undefined : handlePrevPage}
/>
<Text>
{currentPage} / {totalPages}
</Text>
<Icon
name="core/chat/chevronRight"
w="16px"
height="16px"
cursor={currentPage === totalPages ? 'not-allowed' : 'pointer'}
opacity={currentPage === totalPages ? 0.5 : 1}
onClick={currentPage === totalPages ? undefined : handleNextPage}
/>
</Flex>
{totalPages > 1 && (
<Flex gridColumn="3">
<MySelect
value={perPage.toString()}
onChange={handlePerPageChange}
list={[
{ label: t('common:core.chat.table.per_page', { num: 5 }), value: '5' },
{
label: t('common:core.chat.table.per_page', { num: 10 }),
value: '10'
},
{
label: t('common:core.chat.table.per_page', { num: 20 }),
value: '20'
},
{
label: t('common:core.chat.table.per_page', { num: 50 }),
value: '50'
},
{
label: t('common:core.chat.table.per_page', { num: 100 }),
value: '100'
}
]}
/>
</Flex>
)}
</Grid>
</Box>
);
};
export default TableBlock;

View File

@@ -0,0 +1,88 @@
import React, { useState, useMemo } from 'react';
import { Box, Button, Collapse, Flex } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import RemarkMath from 'remark-math';
import RemarkBreaks from 'remark-breaks';
import RehypeKatex from 'rehype-katex';
import RemarkGfm from 'remark-gfm';
import RehypeExternalLinks from 'rehype-external-links';
import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation';
const TextBlock: React.FC<{ content: string }> = ({ content }) => {
const { t } = useSafeTranslation();
const [isExpanded, setIsExpanded] = useState(false);
const { preview, detail, hasNewlines } = useMemo(() => {
const hasNewlines = content.includes('\n');
if (!hasNewlines) {
return { preview: content, detail: '', hasNewlines: false };
}
const lines = content.split('\n');
return {
preview: lines[0],
detail: lines.slice(1).join('\n'),
hasNewlines: true
};
}, [content]);
const buttonProps = {
size: 'xs' as const,
variant: 'ghost' as const,
colorScheme: 'blue' as const,
_hover: { bg: 'blue.50' }
};
return (
<Box
w="90%"
mx="auto"
mt={2}
p={4}
bg="gray.200"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
fontSize="sm"
lineHeight="1.6"
>
<ReactMarkdown
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
>
{preview}
</ReactMarkdown>
{hasNewlines && (
<>
{!isExpanded && (
<Flex justify="flex-end">
<Button {...buttonProps} onClick={() => setIsExpanded(true)}>
{t('common:core.chat.response.Read complete response')}
</Button>
</Flex>
)}
<Collapse in={isExpanded} animateOpacity>
<Box borderTop="1px solid" borderColor="gray.200">
<ReactMarkdown
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
>
{detail}
</ReactMarkdown>
<Flex justify="flex-end">
<Button {...buttonProps} onClick={() => setIsExpanded(false)}>
{t('common:core.chat.response.Fold response')}
</Button>
</Flex>
</Box>
</Collapse>
</>
)}
</Box>
);
};
export default TextBlock;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Text, Flex } from '@chakra-ui/react';
import Icon from '@fastgpt/web/components/common/Icon';
interface TipsProps {
content: string;
type?: 'error' | 'warning';
}
const Tips: React.FC<TipsProps> = ({ content, type = 'error' }) => {
const isError = type === 'error';
return (
<Flex
align="center"
p={4}
bg={isError ? 'red.50' : 'yellow.50'}
border="1px solid"
borderColor={isError ? 'red.200' : 'yellow.200'}
borderRadius="md"
gap={3}
>
<Icon
name={isError ? 'common/errorFill' : 'common/errorFill'}
w="20px"
h="20px"
color={isError ? 'red.500' : 'yellow.500'}
/>
<Text color={isError ? 'red.700' : 'yellow.700'} fontSize="sm" fontWeight="medium">
{content}
</Text>
</Flex>
);
};
export default Tips;

View File

@@ -7,12 +7,42 @@ import { useMount } from 'ahooks';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
import { useScreen } from '@fastgpt/web/hooks/useScreen';
type EChartsGrid = {
top: string;
left: string;
bottom: string;
right: string;
containLabel: boolean;
};
type EChartsSeries = {
data: number[];
name: string;
type: string;
};
type EChartsConfig = {
xAxis: { data: string[]; type: string }[];
yAxis: { type: string }[];
grid: EChartsGrid;
legend: { show: boolean };
series: EChartsSeries[];
tooltip: { trigger: string };
dataZoom: unknown[];
};
const EChartsCodeBlock = ({ code }: { code: string }) => {
const chartRef = useRef<HTMLDivElement>(null);
const eChart = useRef<ECharts>();
const { isPc } = useSystem();
const [option, setOption] = useState<any>();
const [option, setOption] = useState<EChartsConfig>();
const [width, setWidth] = useState(400);
const [dataRange, setDataRange] = useState({ start: 0, end: 100 });
const [totalDataLength, setTotalDataLength] = useState(0);
const [originalXData, setOriginalXData] = useState<string[]>([]);
const [originalYData, setOriginalYData] = useState<number[]>([]);
const dragStartTime = useRef<number>(0);
const [isDragging, setIsDragging] = useState(false);
const findMarkdownDom = useCallback(() => {
if (!chartRef.current) return;
@@ -29,39 +59,176 @@ const EChartsCodeBlock = ({ code }: { code: string }) => {
return parent?.parentElement;
}, [isPc]);
// filter data
const filterDataByRange = useCallback(
(originalXData: string[], originalYData: number[], range: { start: number; end: number }) => {
if (!originalXData.length || !originalYData.length) return { xData: [], yData: [] };
const totalLength = Math.min(originalXData.length, originalYData.length);
const startIndex = Math.floor((range.start / 100) * totalLength);
const endIndex = Math.min(totalLength, Math.ceil((range.end / 100) * totalLength));
const actualEndIndex = Math.max(startIndex + 1, endIndex);
// slice data
const filteredXData = originalXData.slice(startIndex, actualEndIndex);
const filteredYData = originalYData.slice(startIndex, actualEndIndex);
return { xData: filteredXData, yData: filteredYData };
},
[]
);
// x and y data extraction
const extractXYData = useCallback((echartsConfig: EChartsConfig) => {
const emptyResult = {
xData: [] as string[],
yData: [] as number[],
chartContent: null as EChartsConfig | null
};
if (echartsConfig?.series?.length > 0 && echartsConfig?.xAxis?.length > 0) {
const series = echartsConfig.series[0];
const xAxis = echartsConfig.xAxis[0];
return {
xData: xAxis.data || [],
yData: series.data || [],
chartContent: echartsConfig
};
}
return emptyResult;
}, []);
// abstract chart render function
const createChartOption = useCallback(
(xData: string[], yData: number[], chartContent?: EChartsConfig | null) => {
if (chartContent) {
return {
...chartContent,
xAxis: chartContent.xAxis.map((axis) => ({
...axis,
data: xData
})),
series: chartContent.series.map((series) => ({
...series,
data: yData
}))
};
}
// fallback to default config
return {
grid: {
bottom: '15%',
left: '5%',
right: '5%',
top: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
boundaryGap: true,
axisTick: {
alignWithLabel: true,
interval: 0
},
axisLabel: {
interval: (() => {
const dataLength = xData.length;
if (dataLength <= 10) return 0;
if (dataLength <= 20) return 1;
if (dataLength <= 50) return Math.floor(dataLength / 10);
return Math.floor(dataLength / 15);
})(),
rotate: 45,
fontSize: 10,
formatter: (value: string) => {
return value && value.length > 20 ? `${value.substring(0, 20)}...` : value;
}
}
},
yAxis: {
type: 'value',
scale: true
},
series: [
{
type: 'bar',
data: yData,
barCategoryGap: '20%',
itemStyle: {
borderRadius: [4, 4, 0, 0]
},
name: 'Data'
}
],
tooltip: {
trigger: 'axis',
formatter: function (params: Array<{ name: string; seriesName: string; value: number }>) {
if (Array.isArray(params) && params.length > 0) {
const param = params[0];
return `${param.name}<br/>${param.seriesName}: ${param.value}`;
}
return '';
}
}
};
},
[]
);
useMount(() => {
// @ts-ignore
import('echarts-gl');
});
// generate and update chart option
useLayoutEffect(() => {
const option = (() => {
try {
const parse = {
...json5.parse(code.trim()),
toolbox: {
// show: true,
feature: {
saveAsImage: {}
}
}
};
try {
const rawConfig: EChartsConfig = json5.parse(code.trim());
return parse;
} catch (error) {}
})();
const { xData, yData, chartContent } = extractXYData(rawConfig);
setOption(option ?? {});
if (!option) return;
if (chartRef.current) {
try {
eChart.current = echarts.init(chartRef.current);
eChart.current.setOption(option);
} catch (error) {
console.error('ECharts render failed:', error);
if (xData.length === 0 || yData.length === 0) {
return;
}
setOriginalXData(xData);
setOriginalYData(yData);
setTotalDataLength(Math.min(xData.length, yData.length));
const { xData: filteredXData, yData: filteredYData } = filterDataByRange(
xData,
yData,
dataRange
);
const chartOption = createChartOption(filteredXData, filteredYData, chartContent);
// Add toolbox for image saving
const RenderOption = {
...chartOption,
toolbox: {
feature: {
saveAsImage: {}
}
}
};
setOption(RenderOption as EChartsConfig);
if (chartRef.current) {
if (!eChart.current) {
eChart.current = echarts.init(chartRef.current);
}
eChart.current.setOption(RenderOption);
}
} catch (error) {
console.error('ECharts render failed:', error);
}
findMarkdownDom();
@@ -69,25 +236,159 @@ const EChartsCodeBlock = ({ code }: { code: string }) => {
return () => {
if (eChart.current) {
eChart.current.dispose();
eChart.current = undefined;
}
};
}, [code, findMarkdownDom]);
}, [code, findMarkdownDom, filterDataByRange, dataRange, createChartOption, extractXYData]);
const { screenWidth } = useScreen();
useEffect(() => {
findMarkdownDom();
}, [screenWidth]);
}, [screenWidth, findMarkdownDom]);
useEffect(() => {
eChart.current?.resize();
}, [width]);
// slider control
const handleRangeChange = useCallback((newRange: { start: number; end: number }) => {
setDataRange(newRange);
}, []);
// handle drag
const handleDrag = useCallback(
(type: 'left' | 'right' | 'range', e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(false);
dragStartTime.current = Date.now();
const startX = e.clientX;
const { start: startValue, end: endValue } = dataRange;
const rangeWidth = endValue - startValue;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = Math.abs(moveEvent.clientX - startX);
const timeDelta = Date.now() - dragStartTime.current;
if (deltaX > 5 || timeDelta > 100) {
setIsDragging(true);
}
const deltaPercent = ((moveEvent.clientX - startX) / Math.max(width, 400)) * 100;
// drag handle
if (type === 'left') {
const newStart = Math.max(0, Math.min(startValue + deltaPercent, endValue));
handleRangeChange({ start: newStart, end: endValue });
} else if (type === 'right') {
const newEnd = Math.min(100, Math.max(endValue + deltaPercent, startValue));
handleRangeChange({ start: startValue, end: newEnd });
} else if (type === 'range') {
const newStart = Math.max(0, Math.min(startValue + deltaPercent, 100 - rangeWidth));
handleRangeChange({ start: newStart, end: newStart + rangeWidth });
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
setTimeout(() => setIsDragging(false), 100);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[dataRange, width, handleRangeChange]
);
return (
<Box overflowX={'auto'} bg={'white'} borderRadius={'md'}>
<Box h={'400px'} w={`${width}px`} ref={chartRef} />
{!option && (
<Skeleton isLoaded={true} fadeDuration={2} h={'400px'} w={`${width}px`}></Skeleton>
)}
{/* data range slider */}
{option && totalDataLength > 1 && (
<Box borderTop="1px solid #e2e8f0">
<Box
position="relative"
h="40px"
w={`${width}px`}
minW="400px"
bg="gray.50"
borderRadius="md"
overflow="hidden"
cursor="pointer"
onClick={(e) => {
if (!isDragging) {
const rect = e.currentTarget.getBoundingClientRect();
const percentage = ((e.clientX - rect.left) / rect.width) * 100;
const halfWidth = 10;
const newStart = Math.max(0, Math.min(percentage - halfWidth, 80));
const newEnd = Math.min(100, Math.max(percentage + halfWidth, 20));
handleRangeChange({ start: newStart, end: newEnd });
}
}}
>
{/* data thumbnail */}
{originalYData.length > 0 && (
<svg width="100%" height="100%" style={{ position: 'absolute' }}>
<polyline
points={(() => {
const maxVal = Math.max(...originalYData);
const minVal = Math.min(...originalYData);
const range = maxVal - minVal || 1;
return originalYData
.map((value, index) => {
const x = (index / (originalYData.length - 1)) * (width - 8) + 4;
const y = 36 - ((value - minVal) / range) * 32;
return `${x},${y}`;
})
.join(' ');
})()}
fill="none"
stroke="#93c5fd"
strokeWidth="1.5"
opacity="0.7"
/>
</svg>
)}
{/* select area */}
<Box
position="absolute"
left={`${dataRange.start}%`}
width={`${dataRange.end - dataRange.start}%`}
h="100%"
bg="rgba(59, 130, 246, 0.2)"
cursor="ew-resize"
onMouseDown={(e) => handleDrag('range', e)}
/>
{/* left and right drag handle */}
<Box
position="absolute"
left={`${dataRange.start}%`}
w="8px"
h="100%"
cursor="ew-resize"
transform="translateX(-50%)"
onMouseDown={(e) => handleDrag('left', e)}
/>
<Box
position="absolute"
left={`${dataRange.end}%`}
w="8px"
h="100%"
cursor="ew-resize"
transform="translateX(-50%)"
onMouseDown={(e) => handleDrag('right', e)}
/>
</Box>
</Box>
)}
</Box>
);
};

View File

@@ -1,19 +1,22 @@
import 'katex/dist/katex.min.css';
import React, { useCallback, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import RemarkMath from 'remark-math'; // Math syntax
import RemarkBreaks from 'remark-breaks'; // Line break
import RehypeKatex from 'rehype-katex'; // Math render
import RemarkGfm from 'remark-gfm'; // Special markdown syntax
import RehypeExternalLinks from 'rehype-external-links';
import RehypeKatex from 'rehype-katex'; // Math render
import RehypeRaw from 'rehype-raw';
import RemarkBreaks from 'remark-breaks'; // Line break
import RemarkGfm from 'remark-gfm'; // Special markdown syntax
import RemarkMath from 'remark-math'; // Math syntax
import styles from './index.module.scss';
import dynamic from 'next/dynamic';
import styles from './index.module.scss';
import { Box } from '@chakra-ui/react';
import { CodeClassNameEnum, mdTextFormat } from './utils';
import { useCreation } from 'ahooks';
import type { AProps } from './A';
import { CodeClassNameEnum } from './utils';
import DomPurify from 'dompurify';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
@@ -23,11 +26,19 @@ const IframeCodeBlock = dynamic(() => import('./codeBlock/Iframe'), { ssr: false
const IframeHtmlCodeBlock = dynamic(() => import('./codeBlock/iframe-html'), { ssr: false });
const VideoBlock = dynamic(() => import('./codeBlock/Video'), { ssr: false });
const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const TableBlock = dynamic(() => import('./codeBlock/Table'), { ssr: false });
const IndicatorCard = dynamic(() => import('./codeBlock/IndicatorCard'), { ssr: false });
const LinkBlock = dynamic(() => import('./codeBlock/Link'), { ssr: false });
const Tips = dynamic(() => import('./codeBlock/Tips'), { ssr: false });
const Divider = dynamic(() => import('./codeBlock/Divider'), { ssr: false });
const TextBlock = dynamic(() => import('./codeBlock/TextBlock'), { ssr: false });
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
const A = dynamic(() => import('./A'), { ssr: false });
const formatCodeBlock = (lang: string, content: string) => `\`\`\`${lang}\n${content}\n\`\`\``;
type Props = {
source?: string;
showAnimation?: boolean;
@@ -68,10 +79,79 @@ const MarkdownRender = ({
};
}, [chatAuthData, onOpenCiteModal, showAnimation]);
// convert single item to Markdown
const convertRenderBlockToMarkdown = useCallback((jsonContent: string): string => {
const converItem = (type: string, content: any) => {
switch (type) {
case 'TEXT':
return (typeof content === 'string' ? content : JSON.stringify(content)) + '\n\n';
case 'CHART':
return content?.hasChart && content?.echartsData
? `\`\`\`echarts\n${JSON.stringify(content.echartsData, null, 2)}\n\`\`\`\n\n`
: '';
case 'TABLE':
return content?.data
? `\`\`\`table\n${JSON.stringify(content.data, null, 2)}\n\`\`\`\n\n`
: '';
case 'INDICATOR':
return content?.dataList
? `\`\`\`indicator\n${JSON.stringify(content.dataList, null, 2)}\n\`\`\`\n\n`
: '';
case 'LINK':
return content?.text && content?.url
? `\`\`\`link\n${JSON.stringify(content, null, 2)}\n\`\`\`\n\n`
: '';
case 'ERROR_TIPS':
return content ? `\`\`\`error_tips\n${content}\n\`\`\`\n\n` : '';
case 'WARNING_TIPS':
return content ? `\`\`\`warning_tips\n${content}\n\`\`\`\n\n` : '';
case 'DIVIDER':
return `\`\`\`divider\n\n\`\`\`\n\n`;
case 'TEXTBLOCK':
return content ? `\`\`\`textblock\n${content}\n\`\`\`\n\n` : '';
default:
return formatCodeBlock('json', jsonContent);
}
};
try {
const jsonObj = JSON.parse(jsonContent);
if (Array.isArray(jsonObj)) {
return jsonObj.map((item) => converItem(item.type, item.content)).join(`\n\n`);
} else {
return converItem(jsonObj.type, jsonObj.content);
}
} catch {
return formatCodeBlock('json', jsonContent);
}
}, []);
const formatSource = useMemo(() => {
if (showAnimation || forbidZhFormat) return source;
return mdTextFormat(source);
}, [forbidZhFormat, showAnimation, source]);
const result = source.replace(/```RENDER([\s\S]*?)```/g, (match, p1) => {
// p1: the content inside ```RENDER ... ```
const cleanedContent = p1
.replace(/^```[\s\S]*?(\n)?/, '')
.replace(/```$/, '')
.trim();
return convertRenderBlockToMarkdown(cleanedContent);
});
return result;
}, [convertRenderBlockToMarkdown, forbidZhFormat, showAnimation, source]);
const sanitizedSource = useMemo(() => {
return DomPurify.sanitize(formatSource);
}, [formatSource]);
const urlTransform = useCallback((val: string) => {
return val;
@@ -81,14 +161,38 @@ const MarkdownRender = ({
<Box position={'relative'}>
<ReactMarkdown
className={`markdown ${styles.markdown}
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
${showAnimation ? `${sanitizedSource ? styles.waitingAnimation : styles.animation}` : ''}
`}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
rehypePlugins={[
RehypeKatex,
[RehypeExternalLinks, { target: '_blank' }],
[
RehypeRaw,
{
tagfilter: [
'script',
'style',
'iframe',
'frame',
'frameset',
'object',
'embed',
'link',
'meta',
'base',
'form',
'input',
'button',
'img'
]
}
]
]}
components={components}
urlTransform={urlTransform}
>
{formatSource}
{sanitizedSource}
</ReactMarkdown>
{isDisabled && <Box position={'absolute'} top={0} right={0} left={0} bottom={0} />}
</Box>
@@ -134,6 +238,27 @@ function Code(e: any) {
if (codeType === CodeClassNameEnum.audio) {
return <AudioBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.table) {
return <TableBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.indicator) {
return <IndicatorCard dataList={JSON.parse(strChildren)} />;
}
if (codeType === CodeClassNameEnum.link) {
return <LinkBlock data={JSON.parse(strChildren)} />;
}
if (codeType === CodeClassNameEnum.error_tips) {
return <Tips content={strChildren} type="error" />;
}
if (codeType === CodeClassNameEnum.warning_tips) {
return <Tips content={strChildren} type="warning" />;
}
if (codeType === CodeClassNameEnum.divider) {
return <Divider />;
}
if (codeType === CodeClassNameEnum.textblock) {
return <TextBlock content={strChildren} />;
}
return (
<CodeLight className={className} codeBlock={codeBlock} match={match}>

View File

@@ -10,7 +10,14 @@ export enum CodeClassNameEnum {
html = 'html',
svg = 'svg',
video = 'video',
audio = 'audio'
audio = 'audio',
table = 'table',
indicator = 'indicator',
link = 'link',
error_tips = 'error_tips',
warning_tips = 'warning_tips',
divider = 'divider',
textblock = 'textblock'
}
export const mdTextFormat = (text: string) => {