Archer d2948d7e57
feat: markdown extension (#3663)
* feat: markdown extension

* media cros

* rerank test

* default price

* perf: default model

* fix: cannot custom provider

* fix: default model select

* update bg

* perf: default model selector

* fix: usage export

* i18n

* fix: rerank

* update init extension

* perf: ip limit check

* doubao model order

* web default modle

* perf: tts selector

* perf: tts error

* qrcode package
2025-01-24 23:42:04 +08:00

176 lines
5.7 KiB
TypeScript
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.

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 styles from './index.module.scss';
import dynamic from 'next/dynamic';
import { Box } from '@chakra-ui/react';
import { CodeClassNameEnum } from './utils';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
const MdImage = dynamic(() => import('./img/Image'), { ssr: false });
const EChartsCodeBlock = dynamic(() => import('./img/EChartsCodeBlock'), { ssr: false });
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 ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
const A = dynamic(() => import('./A'), { ssr: false });
type Props = {
source?: string;
showAnimation?: boolean;
isDisabled?: boolean;
forbidZhFormat?: boolean;
};
const Markdown = (props: Props) => {
const source = props.source || '';
if (source.length < 200000) {
return <MarkdownRender {...props} />;
}
return <Box whiteSpace={'pre-wrap'}>{source}</Box>;
};
const MarkdownRender = ({ source = '', showAnimation, isDisabled, forbidZhFormat }: Props) => {
const components = useMemo<any>(
() => ({
img: Image,
pre: RewritePre,
code: Code,
a: A
}),
[]
);
const formatSource = useMemo(() => {
if (showAnimation || forbidZhFormat) return source;
// 保护 URL 格式https://, http://, /api/xxx
const urlPlaceholders: string[] = [];
const textWithProtectedUrls = source.replace(
/(https?:\/\/[^\s<]+[^<.,:;"')\]\s]|\/api\/[^\s]+)(?=\s|$)/g,
(match) => {
urlPlaceholders.push(match);
return `__URL_${urlPlaceholders.length - 1}__`;
}
);
// 处理中文与英文数字之间的分词
const textWithSpaces = textWithProtectedUrls
.replace(
/([\u4e00-\u9fa5\u3000-\u303f])([a-zA-Z0-9])|([a-zA-Z0-9])([\u4e00-\u9fa5\u3000-\u303f])/g,
'$1$3 $2$4'
)
// 处理引用标记
.replace(/\n*(\[QUOTE SIGN\]\(.*\))/g, '$1')
// 处理 [quote:id] 格式引用,将 [quote:675934a198f46329dfc6d05a] 转换为 [675934a198f46329dfc6d05a]()
.replace(/\[quote:?\s*([a-f0-9]{24})\](?!\()/gi, '[$1](QUOTE)')
.replace(/\[([a-f0-9]{24})\](?!\()/g, '[$1](QUOTE)');
// 还原 URL
const finalText = textWithSpaces.replace(
/__URL_(\d+)__/g,
(_, index) => urlPlaceholders[parseInt(index)]
);
return finalText;
}, [forbidZhFormat, showAnimation, source]);
const urlTransform = useCallback((val: string) => {
return val;
}, []);
return (
<Box position={'relative'}>
<ReactMarkdown
className={`markdown ${styles.markdown}
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
`}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={[RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]}
components={components}
urlTransform={urlTransform}
>
{formatSource}
</ReactMarkdown>
{isDisabled && <Box position={'absolute'} top={0} right={0} left={0} bottom={0} />}
</Box>
);
};
export default React.memo(Markdown);
/* Custom dom */
function Code(e: any) {
const { className, codeBlock, children } = e;
const match = /language-(\w+)/.exec(className || '');
const codeType = match?.[1];
const strChildren = String(children);
const Component = useMemo(() => {
if (codeType === CodeClassNameEnum.mermaid) {
return <MermaidCodeBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.guide) {
return <ChatGuide text={strChildren} />;
}
if (codeType === CodeClassNameEnum.questionGuide) {
return <QuestionGuide text={strChildren} />;
}
if (codeType === CodeClassNameEnum.echarts) {
return <EChartsCodeBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.iframe) {
return <IframeCodeBlock code={strChildren} />;
}
if (codeType && codeType.toLowerCase() === CodeClassNameEnum.html) {
return (
<IframeHtmlCodeBlock className={className} codeBlock={codeBlock} match={match}>
{children}
</IframeHtmlCodeBlock>
);
}
if (codeType === CodeClassNameEnum.video) {
return <VideoBlock code={strChildren} />;
}
if (codeType === CodeClassNameEnum.audio) {
return <AudioBlock code={strChildren} />;
}
return (
<CodeLight className={className} codeBlock={codeBlock} match={match}>
{children}
</CodeLight>
);
}, [codeType, className, codeBlock, match, children, strChildren]);
return Component;
}
function Image({ src }: { src?: string }) {
return <MdImage src={src} />;
}
function RewritePre({ children }: any) {
const modifiedChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// @ts-ignore
return React.cloneElement(child, { codeBlock: true });
}
return child;
});
return <>{modifiedChildren}</>;
}