import Image from '@/components/image'; import SvgIcon from '@/components/svg-icon'; import { IReference, IReferenceChunk } from '@/interfaces/database/chat'; import { getExtension } from '@/utils/document-util'; import { InfoCircleOutlined } from '@ant-design/icons'; import DOMPurify from 'dompurify'; import React, { memo, useCallback, useEffect, useMemo } from 'react'; import Markdown from 'react-markdown'; import SyntaxHighlighter from 'react-syntax-highlighter'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import { visitParents } from 'unist-util-visit-parents'; import { useTranslation } from 'react-i18next'; import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import ImageCarousel from '@/components/markdown-content/image-carousel'; import { groupConsecutiveReferences, shouldShowCarousel, type ReferenceGroup, } from '@/components/markdown-content/reference-utils'; import { preprocessLaTeX, replaceTextByOldReg, replaceThinkToSection, showImage, } from '@/utils/chat'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request'; import classNames from 'classnames'; import { omit } from 'lodash'; import { pipe } from 'lodash/fp'; // Defining Tailwind CSS class name constants const styles = { referenceChunkImage: 'w-[10vw] object-contain', referenceInnerChunkImage: 'block object-contain max-w-full max-h-[6vh]', referenceImagePreview: 'max-w-[45vw] max-h-[45vh]', chunkContentText: 'max-h-[45vh] overflow-y-auto', documentLink: 'p-0', referenceIcon: 'px-[6px]', fileThumbnail: 'inline-block max-w-[40px]', }; // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem. const MarkdownContent = ({ reference, clickDocumentButton, content, }: { content: string; loading: boolean; reference: IReference; clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void; }) => { const { t } = useTranslation(); const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds(); const contentWithCursor = useMemo(() => { let text = DOMPurify.sanitize(content, { ADD_TAGS: ['think', 'section'], ADD_ATTR: ['class'], }); // let text = content; if (text === '') { text = t('chat.searching'); } const nextText = replaceTextByOldReg(text); return pipe(replaceThinkToSection, preprocessLaTeX)(nextText); }, [content, t]); useEffect(() => { const docAggs = reference?.doc_aggs; setDocumentIds(Array.isArray(docAggs) ? docAggs.map((x) => x.doc_id) : []); }, [reference, setDocumentIds]); const handleDocumentButtonClick = useCallback( ( documentId: string, chunk: IReferenceChunk, isPdf: boolean = false, documentUrl?: string, ) => () => { clickDocumentButton?.(documentId, chunk); }, [clickDocumentButton], ); const rehypeWrapReference = () => { return function wrapTextTransform(tree: any) { visitParents(tree, 'text', (node, ancestors) => { const latestAncestor = ancestors.at(-1); if ( latestAncestor.tagName !== 'custom-typography' && latestAncestor.tagName !== 'code' ) { node.type = 'element'; node.tagName = 'custom-typography'; node.properties = {}; node.children = [{ type: 'text', value: node.value }]; } }); }; }; const getReferenceInfo = useCallback( (chunkIndex: number) => { const chunks = reference?.chunks ?? []; const chunkItem = chunks[chunkIndex]; const document = reference?.doc_aggs?.find( (x) => x?.doc_id === chunkItem?.document_id, ); const documentId = document?.doc_id; const documentUrl = document?.url; const fileThumbnail = documentId ? fileThumbnails[documentId] : ''; const fileExtension = documentId ? getExtension(document?.doc_name) : ''; const imageId = chunkItem?.image_id; return { documentUrl, fileThumbnail, fileExtension, imageId, chunkItem, documentId, document, }; }, [fileThumbnails, reference], ); const getPopoverContent = useCallback( (chunkIndex: number) => { const { fileThumbnail, fileExtension, imageId, chunkItem, documentId, document, } = getReferenceInfo(chunkIndex); return (
{imageId && ( )}
{documentId && (
{fileThumbnail ? ( ) : ( )}
)}
); }, [getReferenceInfo, handleDocumentButtonClick], ); const renderReference = useCallback( (text: string) => { const groups = groupConsecutiveReferences(text); const elements: React.ReactNode[] = []; let lastIndex = 0; groups.forEach((group: ReferenceGroup) => { // Add text before the group if (group[0].start > lastIndex) { elements.push(text.slice(lastIndex, group[0].start)); } // Determine if this group should be a carousel if (shouldShowCarousel(group, reference)) { // Render carousel for consecutive image group elements.push( , ); } else { // Render individual references in the group group.forEach((ref) => { const chunkIndex = Number(ref.id); const { imageId, chunkItem, documentId } = getReferenceInfo(chunkIndex); const docType = chunkItem?.doc_type; if (showImage(docType)) { elements.push( {} } />, ); } else { elements.push( {getPopoverContent(chunkIndex)} , ); } // Add the original reference text elements.push(ref.fullMatch); }); } lastIndex = group[group.length - 1].end; }); // Add any remaining text after the last group if (lastIndex < text.length) { elements.push(text.slice(lastIndex)); } return elements; }, [ reference, fileThumbnails, handleDocumentButtonClick, getReferenceInfo, getPopoverContent, ], ); return ( Array.isArray(renderReference(children)) ? renderReference(children).map((element, index) => ( {element} )) : renderReference(children), code(props: any) { const { children, className, ...rest } = props; const restProps = omit(rest, 'node'); const match = /language-(\w+)/.exec(className || ''); return match ? ( {String(children).replace(/\n$/, '')} ) : ( {children} ); }, } as any } > {contentWithCursor} ); }; export default memo(MarkdownContent);