* feat: Remove duplicate \n from record.progress_msg * feat: add DocumentPreviewer for chunk of chat referencetags/v0.1.0
| .documentContainer { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| position: relative; | |||||
| :global(.PdfHighlighter) { | |||||
| overflow-x: hidden; | |||||
| } | |||||
| :global(.Highlight--scrolledTo .Highlight__part) { | |||||
| overflow-x: hidden; | |||||
| background-color: rgba(255, 226, 143, 1); | |||||
| } | |||||
| } | 
| import { | |||||
| useGetChunkHighlights, | |||||
| useGetDocumentUrl, | |||||
| } from '@/hooks/documentHooks'; | |||||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||||
| import { Skeleton } from 'antd'; | |||||
| import { useEffect, useRef, useState } from 'react'; | |||||
| import { | |||||
| AreaHighlight, | |||||
| Highlight, | |||||
| IHighlight, | |||||
| PdfHighlighter, | |||||
| PdfLoader, | |||||
| Popup, | |||||
| } from 'react-pdf-highlighter'; | |||||
| import styles from './index.less'; | |||||
| interface IProps { | |||||
| chunk: IChunk; | |||||
| documentId: string; | |||||
| visible: boolean; | |||||
| } | |||||
| const HighlightPopup = ({ | |||||
| comment, | |||||
| }: { | |||||
| comment: { text: string; emoji: string }; | |||||
| }) => | |||||
| comment.text ? ( | |||||
| <div className="Highlight__popup"> | |||||
| {comment.emoji} {comment.text} | |||||
| </div> | |||||
| ) : null; | |||||
| const DocumentPreviewer = ({ chunk, documentId, visible }: IProps) => { | |||||
| const url = useGetDocumentUrl(documentId); | |||||
| const state = useGetChunkHighlights(chunk); | |||||
| const ref = useRef<(highlight: IHighlight) => void>(() => {}); | |||||
| const [loaded, setLoaded] = useState(false); | |||||
| const resetHash = () => {}; | |||||
| useEffect(() => { | |||||
| setLoaded(visible); | |||||
| }, [visible]); | |||||
| useEffect(() => { | |||||
| if (state.length > 0 && loaded) { | |||||
| setLoaded(false); | |||||
| ref.current(state[0]); | |||||
| } | |||||
| }, [state, loaded]); | |||||
| return ( | |||||
| <div className={styles.documentContainer}> | |||||
| <PdfLoader url={url} beforeLoad={<Skeleton active />}> | |||||
| {(pdfDocument) => ( | |||||
| <PdfHighlighter | |||||
| pdfDocument={pdfDocument} | |||||
| enableAreaSelection={(event) => event.altKey} | |||||
| onScrollChange={resetHash} | |||||
| scrollRef={(scrollTo) => { | |||||
| ref.current = scrollTo; | |||||
| setLoaded(true); | |||||
| }} | |||||
| onSelectionFinished={() => null} | |||||
| highlightTransform={( | |||||
| highlight, | |||||
| index, | |||||
| setTip, | |||||
| hideTip, | |||||
| viewportToScaled, | |||||
| screenshot, | |||||
| isScrolledTo, | |||||
| ) => { | |||||
| const isTextHighlight = !Boolean( | |||||
| highlight.content && highlight.content.image, | |||||
| ); | |||||
| const component = isTextHighlight ? ( | |||||
| <Highlight | |||||
| isScrolledTo={isScrolledTo} | |||||
| position={highlight.position} | |||||
| comment={highlight.comment} | |||||
| /> | |||||
| ) : ( | |||||
| <AreaHighlight | |||||
| isScrolledTo={isScrolledTo} | |||||
| highlight={highlight} | |||||
| onChange={() => {}} | |||||
| /> | |||||
| ); | |||||
| return ( | |||||
| <Popup | |||||
| popupContent={<HighlightPopup {...highlight} />} | |||||
| onMouseOver={(popupContent) => | |||||
| setTip(highlight, () => popupContent) | |||||
| } | |||||
| onMouseOut={hideTip} | |||||
| key={index} | |||||
| > | |||||
| {component} | |||||
| </Popup> | |||||
| ); | |||||
| }} | |||||
| highlights={state} | |||||
| /> | |||||
| )} | |||||
| </PdfLoader> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| export default DocumentPreviewer; | 
| import { IChunk } from '@/interfaces/database/knowledge'; | |||||
| import { api_host } from '@/utils/api'; | |||||
| import { buildChunkHighlights } from '@/utils/documentUtils'; | |||||
| import { useMemo } from 'react'; | |||||
| import { IHighlight } from 'react-pdf-highlighter'; | |||||
| export const useGetDocumentUrl = (documentId: string) => { | |||||
| const url = useMemo(() => { | |||||
| return `${api_host}/document/get/${documentId}`; | |||||
| }, [documentId]); | |||||
| return url; | |||||
| }; | |||||
| export const useGetChunkHighlights = (selectedChunk: IChunk): IHighlight[] => { | |||||
| const highlights: IHighlight[] = useMemo(() => { | |||||
| return buildChunkHighlights(selectedChunk); | |||||
| }, [selectedChunk]); | |||||
| return highlights; | |||||
| }; | 
| import { MessageType } from '@/constants/chat'; | import { MessageType } from '@/constants/chat'; | ||||
| import { IChunk } from './knowledge'; | |||||
| export interface PromptConfig { | export interface PromptConfig { | ||||
| empty_response: string; | empty_response: string; | ||||
| } | } | ||||
| export interface IReference { | export interface IReference { | ||||
| chunks: Chunk[]; | |||||
| chunks: IChunk[]; | |||||
| doc_aggs: Docagg[]; | doc_aggs: Docagg[]; | ||||
| total: number; | total: number; | ||||
| } | } | ||||
| doc_name: string; | doc_name: string; | ||||
| } | } | ||||
| interface Chunk { | |||||
| chunk_id: string; | |||||
| content_ltks: string; | |||||
| content_with_weight: string; | |||||
| doc_id: string; | |||||
| docnm_kwd: string; | |||||
| img_id: string; | |||||
| important_kwd: any[]; | |||||
| kb_id: string; | |||||
| similarity: number; | |||||
| term_similarity: number; | |||||
| vector_similarity: number; | |||||
| } | |||||
| // interface Chunk { | |||||
| // chunk_id: string; | |||||
| // content_ltks: string; | |||||
| // content_with_weight: string; | |||||
| // doc_id: string; | |||||
| // docnm_kwd: string; | |||||
| // img_id: string; | |||||
| // important_kwd: any[]; | |||||
| // kb_id: string; | |||||
| // similarity: number; | |||||
| // term_similarity: number; | |||||
| // vector_similarity: number; | |||||
| // } | 
| .documentContainer { | .documentContainer { | ||||
| width: 100%; | width: 100%; | ||||
| height: calc(100vh - 284px); | height: calc(100vh - 284px); | ||||
| // overflow-y: auto; | |||||
| // overflow-x: hidden; | |||||
| position: relative; | position: relative; | ||||
| :global(.PdfHighlighter) { | :global(.PdfHighlighter) { | ||||
| overflow-x: hidden; | overflow-x: hidden; | 
| interface IProps { | interface IProps { | ||||
| selectedChunkId: string; | selectedChunkId: string; | ||||
| } | } | ||||
| const HighlightPopup = ({ | const HighlightPopup = ({ | ||||
| comment, | comment, | ||||
| }: { | }: { | ||||
| </div> | </div> | ||||
| ) : null; | ) : null; | ||||
| // TODO: merge with DocumentPreviewer | |||||
| const Preview = ({ selectedChunkId }: IProps) => { | const Preview = ({ selectedChunkId }: IProps) => { | ||||
| const url = useGetDocumentUrl(); | const url = useGetDocumentUrl(); | ||||
| const state = useGetChunkHighlights(selectedChunkId); | const state = useGetChunkHighlights(selectedChunkId); | 
| import { IChunk, IKnowledgeFile } from '@/interfaces/database/knowledge'; | import { IChunk, IKnowledgeFile } from '@/interfaces/database/knowledge'; | ||||
| import { buildChunkHighlights } from '@/utils/documentUtils'; | |||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useMemo, useState } from 'react'; | ||||
| import { IHighlight } from 'react-pdf-highlighter'; | import { IHighlight } from 'react-pdf-highlighter'; | ||||
| import { useSelector } from 'umi'; | import { useSelector } from 'umi'; | ||||
| import { v4 as uuid } from 'uuid'; | |||||
| export const useSelectDocumentInfo = () => { | export const useSelectDocumentInfo = () => { | ||||
| const documentInfo: IKnowledgeFile = useSelector( | const documentInfo: IKnowledgeFile = useSelector( | ||||
| const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId); | const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId); | ||||
| const highlights: IHighlight[] = useMemo(() => { | const highlights: IHighlight[] = useMemo(() => { | ||||
| return Array.isArray(selectedChunk?.positions) && | |||||
| selectedChunk.positions.every((x) => Array.isArray(x)) | |||||
| ? selectedChunk?.positions?.map((x) => { | |||||
| const actualPositions = x.map((y, index) => | |||||
| index !== 0 ? y / 0.7 : y, | |||||
| ); | |||||
| const boundingRect = { | |||||
| width: 849, | |||||
| height: 1200, | |||||
| x1: actualPositions[1], | |||||
| x2: actualPositions[2], | |||||
| y1: actualPositions[3], | |||||
| y2: actualPositions[4], | |||||
| }; | |||||
| return { | |||||
| id: uuid(), | |||||
| comment: { | |||||
| text: '', | |||||
| emoji: '', | |||||
| }, | |||||
| content: { text: selectedChunk.content_with_weight }, | |||||
| position: { | |||||
| boundingRect: boundingRect, | |||||
| rects: [boundingRect], | |||||
| pageNumber: x[0], | |||||
| }, | |||||
| }; | |||||
| }) | |||||
| : []; | |||||
| return buildChunkHighlights(selectedChunk); | |||||
| }, [selectedChunk]); | }, [selectedChunk]); | ||||
| return highlights; | return highlights; | 
| .popoverContentText { | .popoverContentText { | ||||
| white-space: pre-line; | white-space: pre-line; | ||||
| max-height: 50vh; | |||||
| overflow: auto; | |||||
| .popoverContentErrorLabel { | .popoverContentErrorLabel { | ||||
| color: red; | color: red; | ||||
| } | } | 
| } | } | ||||
| const PopoverContent = ({ record }: IProps) => { | const PopoverContent = ({ record }: IProps) => { | ||||
| const replaceText = (text: string) => { | |||||
| // Remove duplicate \n | |||||
| const nextText = text.replace(/(\n)\1+/g, '$1'); | |||||
| const replacedText = reactStringReplace( | |||||
| nextText, | |||||
| /(\[ERROR\].+\s)/g, | |||||
| (match, i) => { | |||||
| return ( | |||||
| <span key={i} className={styles.popoverContentErrorLabel}> | |||||
| {match} | |||||
| </span> | |||||
| ); | |||||
| }, | |||||
| ); | |||||
| return replacedText; | |||||
| }; | |||||
| const items: DescriptionsProps['items'] = [ | const items: DescriptionsProps['items'] = [ | ||||
| { | { | ||||
| key: 'process_begin_at', | key: 'process_begin_at', | ||||
| { | { | ||||
| key: 'progress_msg', | key: 'progress_msg', | ||||
| label: 'Progress Msg', | label: 'Progress Msg', | ||||
| children: reactStringReplace( | |||||
| record.progress_msg.trim(), | |||||
| /(\[ERROR\].+\s)/g, | |||||
| (match, i) => { | |||||
| return ( | |||||
| <span key={i} className={styles.popoverContentErrorLabel}> | |||||
| {match} | |||||
| </span> | |||||
| ); | |||||
| }, | |||||
| ), | |||||
| children: replaceText(record.progress_msg.trim()), | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| > | > | ||||
| <Input placeholder="" /> | <Input placeholder="" /> | ||||
| </Form.Item> | </Form.Item> | ||||
| <Form.Item name={['prompt_config', 'prologue']} label="Set an opener"> | |||||
| <Form.Item | |||||
| name={['prompt_config', 'prologue']} | |||||
| label="Set an opener" | |||||
| initialValue={"Hi! I'm your assistant, what can I do for you?"} | |||||
| > | |||||
| <Input.TextArea autoSize={{ minRows: 5 }} /> | <Input.TextArea autoSize={{ minRows: 5 }} /> | ||||
| </Form.Item> | </Form.Item> | ||||
| <Form.Item | <Form.Item | 
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | ||||
| import { useSelectUserInfo } from '@/hooks/userSettingHook'; | import { useSelectUserInfo } from '@/hooks/userSettingHook'; | ||||
| import { IReference, Message } from '@/interfaces/database/chat'; | import { IReference, Message } from '@/interfaces/database/chat'; | ||||
| import { Avatar, Button, Flex, Input, List, Popover, Space } from 'antd'; | |||||
| import { | |||||
| Avatar, | |||||
| Button, | |||||
| Drawer, | |||||
| Flex, | |||||
| Input, | |||||
| List, | |||||
| Popover, | |||||
| Space, | |||||
| } from 'antd'; | |||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
| import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; | import { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; | ||||
| import reactStringReplace from 'react-string-replace'; | import reactStringReplace from 'react-string-replace'; | ||||
| import { | import { | ||||
| useClickDrawer, | |||||
| useFetchConversationOnMount, | useFetchConversationOnMount, | ||||
| useGetFileIcon, | useGetFileIcon, | ||||
| useSendMessage, | useSendMessage, | ||||
| import Image from '@/components/image'; | import Image from '@/components/image'; | ||||
| import NewDocumentLink from '@/components/new-document-link'; | import NewDocumentLink from '@/components/new-document-link'; | ||||
| import DocumentPreviewer from '@/components/pdf-previewer'; | |||||
| import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | ||||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||||
| import { InfoCircleOutlined } from '@ant-design/icons'; | import { InfoCircleOutlined } from '@ant-design/icons'; | ||||
| import Markdown from 'react-markdown'; | import Markdown from 'react-markdown'; | ||||
| import { visitParents } from 'unist-util-visit-parents'; | import { visitParents } from 'unist-util-visit-parents'; | ||||
| const MessageItem = ({ | const MessageItem = ({ | ||||
| item, | item, | ||||
| reference, | reference, | ||||
| clickDocumentButton, | |||||
| }: { | }: { | ||||
| item: Message; | item: Message; | ||||
| reference: IReference; | reference: IReference; | ||||
| clickDocumentButton: (documentId: string, chunk: IChunk) => void; | |||||
| }) => { | }) => { | ||||
| const userInfo = useSelectUserInfo(); | const userInfo = useSelectUserInfo(); | ||||
| const fileThumbnails = useSelectFileThumbnails(); | const fileThumbnails = useSelectFileThumbnails(); | ||||
| const isAssistant = item.role === MessageType.Assistant; | const isAssistant = item.role === MessageType.Assistant; | ||||
| const handleDocumentButtonClick = useCallback( | |||||
| (documentId: string, chunk: IChunk) => () => { | |||||
| clickDocumentButton(documentId, chunk); | |||||
| }, | |||||
| [clickDocumentButton], | |||||
| ); | |||||
| const getPopoverContent = useCallback( | const getPopoverContent = useCallback( | ||||
| (chunkIndex: number) => { | (chunkIndex: number) => { | ||||
| const chunks = reference?.chunks ?? []; | const chunks = reference?.chunks ?? []; | ||||
| {documentId && ( | {documentId && ( | ||||
| <Flex gap={'middle'}> | <Flex gap={'middle'}> | ||||
| <img src={fileThumbnails[documentId]} alt="" /> | <img src={fileThumbnails[documentId]} alt="" /> | ||||
| <NewDocumentLink documentId={documentId}> | |||||
| <Button | |||||
| type="link" | |||||
| onClick={handleDocumentButtonClick(documentId, chunkItem)} | |||||
| > | |||||
| {document?.doc_name} | {document?.doc_name} | ||||
| </NewDocumentLink> | |||||
| </Button> | |||||
| </Flex> | </Flex> | ||||
| )} | )} | ||||
| </Space> | </Space> | ||||
| </Flex> | </Flex> | ||||
| ); | ); | ||||
| }, | }, | ||||
| [reference, fileThumbnails], | |||||
| [reference, fileThumbnails, handleDocumentButtonClick], | |||||
| ); | ); | ||||
| const renderReference = useCallback( | const renderReference = useCallback( | ||||
| addNewestConversation, | addNewestConversation, | ||||
| } = useFetchConversationOnMount(); | } = useFetchConversationOnMount(); | ||||
| const { sendMessage } = useSendMessage(); | const { sendMessage } = useSendMessage(); | ||||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||||
| useClickDrawer(); | |||||
| const loading = useOneNamespaceEffectsLoading('chatModel', [ | const loading = useOneNamespaceEffectsLoading('chatModel', [ | ||||
| 'completeConversation', | 'completeConversation', | ||||
| }; | }; | ||||
| return ( | return ( | ||||
| <Flex flex={1} className={styles.chatContainer} vertical> | |||||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||||
| <div> | |||||
| {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} | |||||
| ></MessageItem> | |||||
| ); | |||||
| })} | |||||
| </div> | |||||
| <div ref={ref} /> | |||||
| <> | |||||
| <Flex flex={1} className={styles.chatContainer} vertical> | |||||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||||
| <div> | |||||
| {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> | |||||
| ); | |||||
| })} | |||||
| </div> | |||||
| <div ref={ref} /> | |||||
| </Flex> | |||||
| <Input | |||||
| size="large" | |||||
| placeholder="Message Resume Assistant..." | |||||
| value={value} | |||||
| suffix={ | |||||
| <Button type="primary" onClick={handlePressEnter} loading={loading}> | |||||
| Send | |||||
| </Button> | |||||
| } | |||||
| onPressEnter={handlePressEnter} | |||||
| onChange={handleInputChange} | |||||
| /> | |||||
| </Flex> | </Flex> | ||||
| <Input | |||||
| size="large" | |||||
| placeholder="Message Resume Assistant..." | |||||
| value={value} | |||||
| suffix={ | |||||
| <Button type="primary" onClick={handlePressEnter} loading={loading}> | |||||
| 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> | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| import { useSetModalState } from '@/hooks/commonHooks'; | import { useSetModalState } from '@/hooks/commonHooks'; | ||||
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | ||||
| import { IConversation, IDialog } from '@/interfaces/database/chat'; | import { IConversation, IDialog } from '@/interfaces/database/chat'; | ||||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||||
| import { getFileExtension } from '@/utils'; | import { getFileExtension } from '@/utils'; | ||||
| import omit from 'lodash/omit'; | import omit from 'lodash/omit'; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| }; | }; | ||||
| }; | }; | ||||
| export const useClickDrawer = () => { | |||||
| const { visible, showModal, hideModal } = useSetModalState(); | |||||
| const [selectedChunk, setSelectedChunk] = useState<IChunk>({} as IChunk); | |||||
| const [documentId, setDocumentId] = useState<string>(''); | |||||
| const clickDocumentButton = useCallback( | |||||
| (documentId: string, chunk: IChunk) => { | |||||
| showModal(); | |||||
| setSelectedChunk(chunk); | |||||
| setDocumentId(documentId); | |||||
| }, | |||||
| [showModal], | |||||
| ); | |||||
| return { | |||||
| clickDocumentButton, | |||||
| visible, | |||||
| showModal, | |||||
| hideModal, | |||||
| selectedChunk, | |||||
| documentId, | |||||
| }; | |||||
| }; | |||||
| //#endregion | //#endregion | 
| </ModalManager> | </ModalManager> | ||||
| </Space> | </Space> | ||||
| </div> | </div> | ||||
| <Flex | |||||
| gap="large" | |||||
| wrap="wrap" | |||||
| flex={1} | |||||
| // justify="center" | |||||
| className={styles.knowledgeCardContainer} | |||||
| > | |||||
| <Flex gap={'large'} wrap="wrap" className={styles.knowledgeCardContainer}> | |||||
| {list.length > 0 ? ( | {list.length > 0 ? ( | ||||
| list.map((item: any) => { | list.map((item: any) => { | ||||
| return <KnowledgeCard item={item} key={item.name}></KnowledgeCard>; | return <KnowledgeCard item={item} key={item.name}></KnowledgeCard>; | 
| import { IChunk } from '@/interfaces/database/knowledge'; | |||||
| import { v4 as uuid } from 'uuid'; | |||||
| export const buildChunkHighlights = (selectedChunk: IChunk) => { | |||||
| return Array.isArray(selectedChunk?.positions) && | |||||
| selectedChunk.positions.every((x) => Array.isArray(x)) | |||||
| ? selectedChunk?.positions?.map((x) => { | |||||
| const actualPositions = x.map((y, index) => | |||||
| index !== 0 ? y / 0.7 : y, | |||||
| ); | |||||
| const boundingRect = { | |||||
| width: 849, | |||||
| height: 1200, | |||||
| x1: actualPositions[1], | |||||
| x2: actualPositions[2], | |||||
| y1: actualPositions[3], | |||||
| y2: actualPositions[4], | |||||
| }; | |||||
| return { | |||||
| id: uuid(), | |||||
| comment: { | |||||
| text: '', | |||||
| emoji: '', | |||||
| }, | |||||
| content: { text: selectedChunk.content_with_weight }, | |||||
| position: { | |||||
| boundingRect: boundingRect, | |||||
| rects: [boundingRect], | |||||
| pageNumber: x[0], | |||||
| }, | |||||
| }; | |||||
| }) | |||||
| : []; | |||||
| }; |