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
This commit is contained in:
balibabu 2024-03-05 16:30:28 +08:00 committed by GitHub
parent 8a57f2afd5
commit 07d76ea18d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 333 additions and 104 deletions

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;
};

View File

@ -1,4 +1,5 @@
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;
@ -66,7 +67,7 @@ export interface Message {
} }
export interface IReference { export interface IReference {
chunks: Chunk[]; chunks: IChunk[];
doc_aggs: Docagg[]; doc_aggs: Docagg[];
total: number; total: number;
} }
@ -77,16 +78,16 @@ export interface Docagg {
doc_name: string; doc_name: string;
} }
interface Chunk { // interface Chunk {
chunk_id: string; // chunk_id: string;
content_ltks: string; // content_ltks: string;
content_with_weight: string; // content_with_weight: string;
doc_id: string; // doc_id: string;
docnm_kwd: string; // docnm_kwd: string;
img_id: string; // img_id: string;
important_kwd: any[]; // important_kwd: any[];
kb_id: string; // kb_id: string;
similarity: number; // similarity: number;
term_similarity: number; // term_similarity: number;
vector_similarity: number; // vector_similarity: number;
} // }

View File

@ -1,8 +1,6 @@
.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;

View File

@ -16,7 +16,6 @@ import styles from './index.less';
interface IProps { interface IProps {
selectedChunkId: string; selectedChunkId: string;
} }
const HighlightPopup = ({ const HighlightPopup = ({
comment, comment,
}: { }: {
@ -28,6 +27,7 @@ const HighlightPopup = ({
</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);

View File

@ -1,8 +1,8 @@
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(
@ -41,35 +41,7 @@ export const useGetChunkHighlights = (
const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId); const selectedChunk: IChunk = useGetSelectedChunk(selectedChunkId);
const highlights: IHighlight[] = useMemo(() => { const highlights: IHighlight[] = useMemo(() => {
return Array.isArray(selectedChunk?.positions) && return buildChunkHighlights(selectedChunk);
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],
},
};
})
: [];
}, [selectedChunk]); }, [selectedChunk]);
return highlights; return highlights;

View File

@ -8,6 +8,8 @@
.popoverContentText { .popoverContentText {
white-space: pre-line; white-space: pre-line;
max-height: 50vh;
overflow: auto;
.popoverContentErrorLabel { .popoverContentErrorLabel {
color: red; color: red;
} }

View File

@ -21,6 +21,25 @@ interface IProps {
} }
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',
@ -35,17 +54,7 @@ const PopoverContent = ({ record }: IProps) => {
{ {
key: 'progress_msg', key: 'progress_msg',
label: 'Progress Msg', label: 'Progress Msg',
children: reactStringReplace( children: replaceText(record.progress_msg.trim()),
record.progress_msg.trim(),
/(\[ERROR\].+\s)/g,
(match, i) => {
return (
<span key={i} className={styles.popoverContentErrorLabel}>
{match}
</span>
);
},
),
}, },
]; ];

View File

@ -65,7 +65,11 @@ const AssistantSetting = ({ show }: ISegmentedContentProps) => {
> >
<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

View File

@ -3,11 +3,21 @@ import { MessageType } from '@/constants/chat';
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,
@ -15,7 +25,9 @@ import {
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';
@ -41,15 +53,24 @@ const rehypeWrapReference = () => {
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 ?? [];
@ -83,16 +104,19 @@ const MessageItem = ({
{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(
@ -191,6 +215,8 @@ const ChatContainer = () => {
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',
@ -210,6 +236,7 @@ const ChatContainer = () => {
}; };
return ( return (
<>
<Flex flex={1} className={styles.chatContainer} vertical> <Flex flex={1} className={styles.chatContainer} vertical>
<Flex flex={1} vertical className={styles.messageContainer}> <Flex flex={1} vertical className={styles.messageContainer}>
<div> <div>
@ -226,6 +253,7 @@ const ChatContainer = () => {
key={message.id} key={message.id}
item={message} item={message}
reference={reference} reference={reference}
clickDocumentButton={clickDocumentButton}
></MessageItem> ></MessageItem>
); );
})} })}
@ -245,6 +273,19 @@ const ChatContainer = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Flex> </Flex>
<Drawer
title="Document Previewer"
onClose={hideModal}
open={visible}
width={'50vw'}
>
<DocumentPreviewer
documentId={documentId}
chunk={selectedChunk}
visible={visible}
></DocumentPreviewer>
</Drawer>
</>
); );
}; };

View File

@ -4,6 +4,7 @@ import { fileIconMap } from '@/constants/common';
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';
@ -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 //#endregion

View File

@ -50,13 +50,7 @@ const Knowledge = () => {
</ModalManager> </ModalManager>
</Space> </Space>
</div> </div>
<Flex <Flex gap={'large'} wrap="wrap" className={styles.knowledgeCardContainer}>
gap="large"
wrap="wrap"
flex={1}
// justify="center"
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>;

View File

@ -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],
},
};
})
: [];
};