balibabu
feat: add loading to ChatContainer and set font family to inter and add tooltip to Form.Item and download documents on the document list page (#136)
84f80c5
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | |
import Image from '@/components/image'; | |
import NewDocumentLink from '@/components/new-document-link'; | |
import DocumentPreviewer from '@/components/pdf-previewer'; | |
import { MessageType } from '@/constants/chat'; | |
import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |
import { useSelectUserInfo } from '@/hooks/userSettingHook'; | |
import { IReference, Message } from '@/interfaces/database/chat'; | |
import { IChunk } from '@/interfaces/database/knowledge'; | |
import { InfoCircleOutlined } from '@ant-design/icons'; | |
import { | |
Avatar, | |
Button, | |
Drawer, | |
Flex, | |
Input, | |
List, | |
Popover, | |
Skeleton, | |
Space, | |
Spin, | |
} from 'antd'; | |
import classNames from 'classnames'; | |
import { useCallback, useMemo } from 'react'; | |
import Markdown from 'react-markdown'; | |
import reactStringReplace from 'react-string-replace'; | |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; | |
import remarkGfm from 'remark-gfm'; | |
import { visitParents } from 'unist-util-visit-parents'; | |
import { | |
useClickDrawer, | |
useFetchConversationOnMount, | |
useGetFileIcon, | |
useGetSendButtonDisabled, | |
useSelectConversationLoading, | |
useSendMessage, | |
} from '../hooks'; | |
import styles from './index.less'; | |
const reg = /(#{2}\d+\${2})/g; | |
const getChunkIndex = (match: string) => Number(match.slice(2, -2)); | |
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 MessageItem = ({ | |
item, | |
reference, | |
clickDocumentButton, | |
}: { | |
item: Message; | |
reference: IReference; | |
clickDocumentButton: (documentId: string, chunk: IChunk) => void; | |
}) => { | |
const userInfo = useSelectUserInfo(); | |
const fileThumbnails = useSelectFileThumbnails(); | |
const isAssistant = item.role === MessageType.Assistant; | |
const handleDocumentButtonClick = useCallback( | |
(documentId: string, chunk: IChunk) => () => { | |
clickDocumentButton(documentId, chunk); | |
}, | |
[clickDocumentButton], | |
); | |
const getPopoverContent = useCallback( | |
(chunkIndex: number) => { | |
const chunks = reference?.chunks ?? []; | |
const chunkItem = chunks[chunkIndex]; | |
const document = reference?.doc_aggs.find( | |
(x) => x?.doc_id === chunkItem?.doc_id, | |
); | |
const documentId = document?.doc_id; | |
return ( | |
<Flex | |
key={chunkItem?.chunk_id} | |
gap={10} | |
className={styles.referencePopoverWrapper} | |
> | |
<Popover | |
placement="topRight" | |
content={ | |
<Image | |
id={chunkItem?.img_id} | |
className={styles.referenceImagePreview} | |
></Image> | |
} | |
> | |
<Image | |
id={chunkItem?.img_id} | |
className={styles.referenceChunkImage} | |
></Image> | |
</Popover> | |
<Space direction={'vertical'}> | |
<div | |
dangerouslySetInnerHTML={{ | |
__html: chunkItem?.content_with_weight, | |
}} | |
className={styles.chunkContentText} | |
></div> | |
{documentId && ( | |
<Flex gap={'middle'}> | |
<img src={fileThumbnails[documentId]} alt="" /> | |
<Button | |
type="link" | |
onClick={handleDocumentButtonClick(documentId, chunkItem)} | |
> | |
{document?.doc_name} | |
</Button> | |
</Flex> | |
)} | |
</Space> | |
</Flex> | |
); | |
}, | |
[reference, fileThumbnails, handleDocumentButtonClick], | |
); | |
const renderReference = useCallback( | |
(text: string) => { | |
return reactStringReplace(text, reg, (match, i) => { | |
const chunkIndex = getChunkIndex(match); | |
return ( | |
<Popover content={getPopoverContent(chunkIndex)}> | |
<InfoCircleOutlined key={i} className={styles.referenceIcon} /> | |
</Popover> | |
); | |
}); | |
}, | |
[getPopoverContent], | |
); | |
const referenceDocumentList = useMemo(() => { | |
return reference?.doc_aggs ?? []; | |
}, [reference?.doc_aggs]); | |
return ( | |
<div | |
className={classNames(styles.messageItem, { | |
[styles.messageItemLeft]: item.role === MessageType.Assistant, | |
[styles.messageItemRight]: item.role === MessageType.User, | |
})} | |
> | |
<section | |
className={classNames(styles.messageItemSection, { | |
[styles.messageItemSectionLeft]: item.role === MessageType.Assistant, | |
[styles.messageItemSectionRight]: item.role === MessageType.User, | |
})} | |
> | |
<div | |
className={classNames(styles.messageItemContent, { | |
[styles.messageItemContentReverse]: item.role === MessageType.User, | |
})} | |
> | |
{item.role === MessageType.User ? ( | |
<Avatar | |
size={40} | |
src={ | |
userInfo.avatar ?? | |
'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png' | |
} | |
/> | |
) : ( | |
<AssistantIcon></AssistantIcon> | |
)} | |
<Flex vertical gap={8} flex={1}> | |
<b>{isAssistant ? '' : userInfo.nickname}</b> | |
<div className={styles.messageText}> | |
{item.content !== '' ? ( | |
<Markdown | |
rehypePlugins={[rehypeWrapReference]} | |
remarkPlugins={[remarkGfm]} | |
components={ | |
{ | |
'custom-typography': ({ | |
children, | |
}: { | |
children: string; | |
}) => renderReference(children), | |
code(props: any) { | |
const { children, className, node, ...rest } = props; | |
const match = /language-(\w+)/.exec(className || ''); | |
return match ? ( | |
<SyntaxHighlighter | |
{...rest} | |
PreTag="div" | |
language={match[1]} | |
> | |
{String(children).replace(/\n$/, '')} | |
</SyntaxHighlighter> | |
) : ( | |
<code {...rest} className={className}> | |
{children} | |
</code> | |
); | |
}, | |
} as any | |
} | |
> | |
{item.content} | |
</Markdown> | |
) : ( | |
<Skeleton active className={styles.messageEmpty} /> | |
)} | |
</div> | |
{isAssistant && referenceDocumentList.length > 0 && ( | |
<List | |
bordered | |
dataSource={referenceDocumentList} | |
renderItem={(item) => ( | |
<List.Item> | |
{/* <SvgIcon name={getFileIcon(item.doc_name)}></SvgIcon> */} | |
<Flex gap={'middle'}> | |
<img src={fileThumbnails[item.doc_id]}></img> | |
<NewDocumentLink documentId={item.doc_id}> | |
{item.doc_name} | |
</NewDocumentLink> | |
</Flex> | |
</List.Item> | |
)} | |
/> | |
)} | |
</Flex> | |
</div> | |
</section> | |
</div> | |
); | |
}; | |
const ChatContainer = () => { | |
const { | |
ref, | |
currentConversation: conversation, | |
addNewestConversation, | |
removeLatestMessage, | |
} = useFetchConversationOnMount(); | |
const { | |
handleInputChange, | |
handlePressEnter, | |
value, | |
loading: sendLoading, | |
} = useSendMessage(conversation, addNewestConversation, removeLatestMessage); | |
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |
useClickDrawer(); | |
const disabled = useGetSendButtonDisabled(); | |
useGetFileIcon(); | |
const loading = useSelectConversationLoading(); | |
return ( | |
<> | |
<Flex flex={1} className={styles.chatContainer} vertical> | |
<Flex flex={1} vertical className={styles.messageContainer}> | |
<div> | |
<Spin spinning={loading}> | |
{conversation?.message?.map((message) => { | |
const assistantMessages = conversation?.message | |
?.filter((x) => x.role === MessageType.Assistant) | |
.slice(1); | |
const referenceIndex = assistantMessages.findIndex( | |
(x) => x.id === message.id, | |
); | |
const reference = conversation.reference[referenceIndex]; | |
return ( | |
<MessageItem | |
key={message.id} | |
item={message} | |
reference={reference} | |
clickDocumentButton={clickDocumentButton} | |
></MessageItem> | |
); | |
})} | |
</Spin> | |
</div> | |
<div ref={ref} /> | |
</Flex> | |
<Input | |
size="large" | |
placeholder="Message Resume Assistant..." | |
value={value} | |
disabled={disabled} | |
suffix={ | |
<Button | |
type="primary" | |
onClick={handlePressEnter} | |
loading={sendLoading} | |
disabled={disabled} | |
> | |
Send | |
</Button> | |
} | |
onPressEnter={handlePressEnter} | |
onChange={handleInputChange} | |
/> | |
</Flex> | |
<Drawer | |
title="Document Previewer" | |
onClose={hideModal} | |
open={visible} | |
width={'50vw'} | |
> | |
<DocumentPreviewer | |
documentId={documentId} | |
chunk={selectedChunk} | |
visible={visible} | |
></DocumentPreviewer> | |
</Drawer> | |
</> | |
); | |
}; | |
export default ChatContainer; | |