From 07d76ea18dcea63e8ae6af80fd585f39bb509633 Mon Sep 17 00:00:00 2001
From: balibabu <cike8899@users.noreply.github.com>
Date: Tue, 5 Mar 2024 16:30:28 +0800
Subject: [PATCH] feat: add DocumentPreviewer for chunk of chat reference and
 remove duplicate \n from record.progress_msg (#97)

* feat: Remove duplicate \n from record.progress_msg

* feat: add DocumentPreviewer for chunk of chat reference
---
 web/src/components/pdf-previewer/index.less   |  12 ++
 web/src/components/pdf-previewer/index.tsx    | 116 +++++++++++++++++
 web/src/hooks/documentHooks.ts                |  21 ++++
 web/src/interfaces/database/chat.ts           |  29 ++---
 .../components/document-preview/index.less    |   2 -
 .../components/document-preview/preview.tsx   |   2 +-
 .../components/knowledge-chunk/hooks.ts       |  32 +----
 .../parsing-status-cell/index.less            |   2 +
 .../parsing-status-cell/index.tsx             |  31 +++--
 .../assistant-setting.tsx                     |   6 +-
 web/src/pages/chat/chat-container/index.tsx   | 117 ++++++++++++------
 web/src/pages/chat/hooks.ts                   |  25 ++++
 web/src/pages/knowledge/index.tsx             |   8 +-
 web/src/utils/documentUtils.ts                |  34 +++++
 14 files changed, 333 insertions(+), 104 deletions(-)
 create mode 100644 web/src/components/pdf-previewer/index.less
 create mode 100644 web/src/components/pdf-previewer/index.tsx
 create mode 100644 web/src/hooks/documentHooks.ts
 create mode 100644 web/src/utils/documentUtils.ts

diff --git a/web/src/components/pdf-previewer/index.less b/web/src/components/pdf-previewer/index.less
new file mode 100644
index 0000000..b651993
--- /dev/null
+++ b/web/src/components/pdf-previewer/index.less
@@ -0,0 +1,12 @@
+.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);
+  }
+}
diff --git a/web/src/components/pdf-previewer/index.tsx b/web/src/components/pdf-previewer/index.tsx
new file mode 100644
index 0000000..e6f72ef
--- /dev/null
+++ b/web/src/components/pdf-previewer/index.tsx
@@ -0,0 +1,116 @@
+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;
diff --git a/web/src/hooks/documentHooks.ts b/web/src/hooks/documentHooks.ts
new file mode 100644
index 0000000..a79f9d1
--- /dev/null
+++ b/web/src/hooks/documentHooks.ts
@@ -0,0 +1,21 @@
+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;
+};
diff --git a/web/src/interfaces/database/chat.ts b/web/src/interfaces/database/chat.ts
index af6b12c..76b5909 100644
--- a/web/src/interfaces/database/chat.ts
+++ b/web/src/interfaces/database/chat.ts
@@ -1,4 +1,5 @@
 import { MessageType } from '@/constants/chat';
+import { IChunk } from './knowledge';
 
 export interface PromptConfig {
   empty_response: string;
@@ -66,7 +67,7 @@ export interface Message {
 }
 
 export interface IReference {
-  chunks: Chunk[];
+  chunks: IChunk[];
   doc_aggs: Docagg[];
   total: number;
 }
@@ -77,16 +78,16 @@ export interface Docagg {
   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;
+// }
diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/index.less b/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/index.less
index a6e0646..11283f2 100644
--- a/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/index.less
+++ b/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/index.less
@@ -1,8 +1,6 @@
 .documentContainer {
   width: 100%;
   height: calc(100vh - 284px);
-  //   overflow-y: auto;
-  //   overflow-x: hidden;
   position: relative;
   :global(.PdfHighlighter) {
     overflow-x: hidden;
diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/preview.tsx b/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/preview.tsx
index e5c26c2..a7444fe 100644
--- a/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/preview.tsx
+++ b/web/src/pages/add-knowledge/components/knowledge-chunk/components/document-preview/preview.tsx
@@ -16,7 +16,6 @@ import styles from './index.less';
 interface IProps {
   selectedChunkId: string;
 }
-
 const HighlightPopup = ({
   comment,
 }: {
@@ -28,6 +27,7 @@ const HighlightPopup = ({
     </div>
   ) : null;
 
+// TODO: merge with DocumentPreviewer
 const Preview = ({ selectedChunkId }: IProps) => {
   const url = useGetDocumentUrl();
   const state = useGetChunkHighlights(selectedChunkId);
diff --git a/web/src/pages/add-knowledge/components/knowledge-chunk/hooks.ts b/web/src/pages/add-knowledge/components/knowledge-chunk/hooks.ts
index aad1dd0..edd256b 100644
--- a/web/src/pages/add-knowledge/components/knowledge-chunk/hooks.ts
+++ b/web/src/pages/add-knowledge/components/knowledge-chunk/hooks.ts
@@ -1,8 +1,8 @@
 import { IChunk, IKnowledgeFile } from '@/interfaces/database/knowledge';
+import { buildChunkHighlights } from '@/utils/documentUtils';
 import { useCallback, useMemo, useState } from 'react';
 import { IHighlight } from 'react-pdf-highlighter';
 import { useSelector } from 'umi';
-import { v4 as uuid } from 'uuid';
 
 export const useSelectDocumentInfo = () => {
   const documentInfo: IKnowledgeFile = useSelector(
@@ -41,35 +41,7 @@ export const useGetChunkHighlights = (
   const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId);
 
   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]);
 
   return highlights;
diff --git a/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.less b/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.less
index 62dc542..25ccfaa 100644
--- a/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.less
+++ b/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.less
@@ -8,6 +8,8 @@
 
   .popoverContentText {
     white-space: pre-line;
+    max-height: 50vh;
+    overflow: auto;
     .popoverContentErrorLabel {
       color: red;
     }
diff --git a/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.tsx b/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.tsx
index a6053f0..6f21655 100644
--- a/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.tsx
+++ b/web/src/pages/add-knowledge/components/knowledge-file/parsing-status-cell/index.tsx
@@ -21,6 +21,25 @@ interface 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'] = [
     {
       key: 'process_begin_at',
@@ -35,17 +54,7 @@ const PopoverContent = ({ record }: IProps) => {
     {
       key: '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()),
     },
   ];
 
diff --git a/web/src/pages/chat/chat-configuration-modal/assistant-setting.tsx b/web/src/pages/chat/chat-configuration-modal/assistant-setting.tsx
index c2e9c4a..65c7dae 100644
--- a/web/src/pages/chat/chat-configuration-modal/assistant-setting.tsx
+++ b/web/src/pages/chat/chat-configuration-modal/assistant-setting.tsx
@@ -65,7 +65,11 @@ const AssistantSetting = ({ show }: ISegmentedContentProps) => {
       >
         <Input placeholder="" />
       </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 }} />
       </Form.Item>
       <Form.Item
diff --git a/web/src/pages/chat/chat-container/index.tsx b/web/src/pages/chat/chat-container/index.tsx
index 1019e64..39855cb 100644
--- a/web/src/pages/chat/chat-container/index.tsx
+++ b/web/src/pages/chat/chat-container/index.tsx
@@ -3,11 +3,21 @@ import { MessageType } from '@/constants/chat';
 import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks';
 import { useSelectUserInfo } from '@/hooks/userSettingHook';
 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 { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
 import reactStringReplace from 'react-string-replace';
 import {
+  useClickDrawer,
   useFetchConversationOnMount,
   useGetFileIcon,
   useSendMessage,
@@ -15,7 +25,9 @@ import {
 
 import Image from '@/components/image';
 import NewDocumentLink from '@/components/new-document-link';
+import DocumentPreviewer from '@/components/pdf-previewer';
 import { useSelectFileThumbnails } from '@/hooks/knowledgeHook';
+import { IChunk } from '@/interfaces/database/knowledge';
 import { InfoCircleOutlined } from '@ant-design/icons';
 import Markdown from 'react-markdown';
 import { visitParents } from 'unist-util-visit-parents';
@@ -41,15 +53,24 @@ const rehypeWrapReference = () => {
 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 ?? [];
@@ -83,16 +104,19 @@ const MessageItem = ({
             {documentId && (
               <Flex gap={'middle'}>
                 <img src={fileThumbnails[documentId]} alt="" />
-                <NewDocumentLink documentId={documentId}>
+                <Button
+                  type="link"
+                  onClick={handleDocumentButtonClick(documentId, chunkItem)}
+                >
                   {document?.doc_name}
-                </NewDocumentLink>
+                </Button>
               </Flex>
             )}
           </Space>
         </Flex>
       );
     },
-    [reference, fileThumbnails],
+    [reference, fileThumbnails, handleDocumentButtonClick],
   );
 
   const renderReference = useCallback(
@@ -191,6 +215,8 @@ const ChatContainer = () => {
     addNewestConversation,
   } = useFetchConversationOnMount();
   const { sendMessage } = useSendMessage();
+  const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
+    useClickDrawer();
 
   const loading = useOneNamespaceEffectsLoading('chatModel', [
     'completeConversation',
@@ -210,41 +236,56 @@ const ChatContainer = () => {
   };
 
   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>
-      <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>
+    </>
   );
 };
 
diff --git a/web/src/pages/chat/hooks.ts b/web/src/pages/chat/hooks.ts
index 52e1553..1c8926b 100644
--- a/web/src/pages/chat/hooks.ts
+++ b/web/src/pages/chat/hooks.ts
@@ -4,6 +4,7 @@ import { fileIconMap } from '@/constants/common';
 import { useSetModalState } from '@/hooks/commonHooks';
 import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks';
 import { IConversation, IDialog } from '@/interfaces/database/chat';
+import { IChunk } from '@/interfaces/database/knowledge';
 import { getFileExtension } from '@/utils';
 import omit from 'lodash/omit';
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -662,4 +663,28 @@ export const useRenameConversation = () => {
   };
 };
 
+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
diff --git a/web/src/pages/knowledge/index.tsx b/web/src/pages/knowledge/index.tsx
index 76ad371..78f2af5 100644
--- a/web/src/pages/knowledge/index.tsx
+++ b/web/src/pages/knowledge/index.tsx
@@ -50,13 +50,7 @@ const Knowledge = () => {
           </ModalManager>
         </Space>
       </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.map((item: any) => {
             return <KnowledgeCard item={item} key={item.name}></KnowledgeCard>;
diff --git a/web/src/utils/documentUtils.ts b/web/src/utils/documentUtils.ts
new file mode 100644
index 0000000..a2fb1e0
--- /dev/null
+++ b/web/src/utils/documentUtils.ts
@@ -0,0 +1,34 @@
+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],
+          },
+        };
+      })
+    : [];
+};
-- 
GitLab