You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.tsx 7.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import Image from '@/components/image';
  2. import SvgIcon from '@/components/svg-icon';
  3. import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
  4. import { getExtension } from '@/utils/document-util';
  5. import { InfoCircleOutlined } from '@ant-design/icons';
  6. import { Button, Flex, Popover } from 'antd';
  7. import DOMPurify from 'dompurify';
  8. import { useCallback, useEffect, useMemo } from 'react';
  9. import Markdown from 'react-markdown';
  10. import reactStringReplace from 'react-string-replace';
  11. import SyntaxHighlighter from 'react-syntax-highlighter';
  12. import rehypeKatex from 'rehype-katex';
  13. import rehypeRaw from 'rehype-raw';
  14. import remarkGfm from 'remark-gfm';
  15. import remarkMath from 'remark-math';
  16. import { visitParents } from 'unist-util-visit-parents';
  17. import { useFetchDocumentThumbnailsByIds } from '@/hooks/document-hooks';
  18. import { useTranslation } from 'react-i18next';
  19. import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
  20. import { preprocessLaTeX, replaceThinkToSection } from '@/utils/chat';
  21. import { replaceTextByOldReg } from '../utils';
  22. import classNames from 'classnames';
  23. import { pipe } from 'lodash/fp';
  24. import styles from './index.less';
  25. const reg = /(~{2}\d+={2})/g;
  26. // const curReg = /(~{2}\d+\${2})/g;
  27. const getChunkIndex = (match: string) => Number(match.slice(2, -2));
  28. // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
  29. const MarkdownContent = ({
  30. reference,
  31. clickDocumentButton,
  32. content,
  33. }: {
  34. content: string;
  35. loading: boolean;
  36. reference: IReference;
  37. clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
  38. }) => {
  39. const { t } = useTranslation();
  40. const { setDocumentIds, data: fileThumbnails } =
  41. useFetchDocumentThumbnailsByIds();
  42. const contentWithCursor = useMemo(() => {
  43. let text = content;
  44. if (text === '') {
  45. text = t('chat.searching');
  46. }
  47. const nextText = replaceTextByOldReg(text);
  48. return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
  49. }, [content, t]);
  50. useEffect(() => {
  51. const docAggs = reference?.doc_aggs;
  52. setDocumentIds(Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : []);
  53. }, [reference, setDocumentIds]);
  54. const handleDocumentButtonClick = useCallback(
  55. (
  56. documentId: string,
  57. chunk: IReferenceChunk,
  58. isPdf: boolean,
  59. documentUrl?: string,
  60. ) =>
  61. () => {
  62. if (!isPdf) {
  63. if (!documentUrl) {
  64. return;
  65. }
  66. window.open(documentUrl, '_blank');
  67. } else {
  68. clickDocumentButton?.(documentId, chunk);
  69. }
  70. },
  71. [clickDocumentButton],
  72. );
  73. const rehypeWrapReference = () => {
  74. return function wrapTextTransform(tree: any) {
  75. visitParents(tree, 'text', (node, ancestors) => {
  76. const latestAncestor = ancestors.at(-1);
  77. if (
  78. latestAncestor.tagName !== 'custom-typography' &&
  79. latestAncestor.tagName !== 'code'
  80. ) {
  81. node.type = 'element';
  82. node.tagName = 'custom-typography';
  83. node.properties = {};
  84. node.children = [{ type: 'text', value: node.value }];
  85. }
  86. });
  87. };
  88. };
  89. const getPopoverContent = useCallback(
  90. (chunkIndex: number) => {
  91. const chunks = reference?.chunks ?? [];
  92. const chunkItem = chunks[chunkIndex];
  93. const document = reference?.doc_aggs?.find(
  94. (x) => x?.doc_id === chunkItem?.document_id,
  95. );
  96. const documentId = document?.doc_id;
  97. const documentUrl = document?.url;
  98. const fileThumbnail = documentId ? fileThumbnails[documentId] : '';
  99. const fileExtension = documentId ? getExtension(document?.doc_name) : '';
  100. const imageId = chunkItem?.image_id;
  101. return (
  102. <Image id={imageId} className={styles.referenceChunkImage}></Image>
  103. );
  104. return (
  105. <div key={chunkItem?.id} className="flex gap-2">
  106. {imageId && (
  107. <Popover
  108. placement="left"
  109. content={
  110. <Image
  111. id={imageId}
  112. className={styles.referenceImagePreview}
  113. ></Image>
  114. }
  115. >
  116. <Image
  117. id={imageId}
  118. className={styles.referenceChunkImage}
  119. ></Image>
  120. </Popover>
  121. )}
  122. <div className={'space-y-2 max-w-[40vw]'}>
  123. <div
  124. dangerouslySetInnerHTML={{
  125. __html: DOMPurify.sanitize(chunkItem?.content ?? ''),
  126. }}
  127. className={classNames(styles.chunkContentText)}
  128. ></div>
  129. {documentId && (
  130. <Flex gap={'small'}>
  131. {fileThumbnail ? (
  132. <img
  133. src={fileThumbnail}
  134. alt=""
  135. className={styles.fileThumbnail}
  136. />
  137. ) : (
  138. <SvgIcon
  139. name={`file-icon/${fileExtension}`}
  140. width={24}
  141. ></SvgIcon>
  142. )}
  143. <Button
  144. type="link"
  145. className={classNames(styles.documentLink, 'text-wrap')}
  146. onClick={handleDocumentButtonClick(
  147. documentId,
  148. chunkItem,
  149. fileExtension === 'pdf',
  150. documentUrl,
  151. )}
  152. >
  153. {document?.doc_name}
  154. </Button>
  155. </Flex>
  156. )}
  157. </div>
  158. </div>
  159. );
  160. },
  161. [reference, fileThumbnails, handleDocumentButtonClick],
  162. );
  163. const renderReference = useCallback(
  164. (text: string) => {
  165. let replacedText = reactStringReplace(text, reg, (match, i) => {
  166. const chunkIndex = getChunkIndex(match);
  167. return getPopoverContent(chunkIndex);
  168. return (
  169. <Popover content={getPopoverContent(chunkIndex)} key={i}>
  170. <InfoCircleOutlined className={styles.referenceIcon} />
  171. </Popover>
  172. );
  173. });
  174. // replacedText = reactStringReplace(replacedText, curReg, (match, i) => (
  175. // <span className={styles.cursor} key={i}></span>
  176. // ));
  177. return replacedText;
  178. },
  179. [getPopoverContent],
  180. );
  181. return (
  182. <Markdown
  183. rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
  184. remarkPlugins={[remarkGfm, remarkMath]}
  185. className={styles.markdownContentWrapper}
  186. components={
  187. {
  188. 'custom-typography': ({ children }: { children: string }) =>
  189. renderReference(children),
  190. code(props: any) {
  191. const { children, className, node, ...rest } = props;
  192. const match = /language-(\w+)/.exec(className || '');
  193. return match ? (
  194. <SyntaxHighlighter
  195. {...rest}
  196. PreTag="div"
  197. language={match[1]}
  198. wrapLongLines
  199. >
  200. {String(children).replace(/\n$/, '')}
  201. </SyntaxHighlighter>
  202. ) : (
  203. <code {...rest} className={classNames(className, 'text-wrap')}>
  204. {children}
  205. </code>
  206. );
  207. },
  208. } as any
  209. }
  210. >
  211. {contentWithCursor}
  212. </Markdown>
  213. );
  214. };
  215. export default MarkdownContent;