### What problem does this PR solve? feat: add FlowChatBox #918 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.8.0
| @@ -0,0 +1,40 @@ | |||
| .messageItem { | |||
| padding: 24px 0; | |||
| .messageItemSection { | |||
| display: inline-block; | |||
| } | |||
| .messageItemSectionLeft { | |||
| width: 70%; | |||
| } | |||
| .messageItemSectionRight { | |||
| width: 40%; | |||
| } | |||
| .messageItemContent { | |||
| display: inline-flex; | |||
| gap: 20px; | |||
| } | |||
| .messageItemContentReverse { | |||
| flex-direction: row-reverse; | |||
| } | |||
| .messageText { | |||
| .chunkText(); | |||
| padding: 0 14px; | |||
| background-color: rgba(249, 250, 251, 1); | |||
| word-break: break-all; | |||
| } | |||
| .messageEmpty { | |||
| width: 300px; | |||
| } | |||
| .thumbnailImg { | |||
| max-width: 20px; | |||
| } | |||
| } | |||
| .messageItemLeft { | |||
| text-align: left; | |||
| } | |||
| .messageItemRight { | |||
| text-align: right; | |||
| } | |||
| @@ -0,0 +1,128 @@ | |||
| import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { useGetDocumentUrl } from '@/hooks/documentHooks'; | |||
| import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |||
| import { useSelectUserInfo } from '@/hooks/userSettingHook'; | |||
| import { IReference, Message } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import classNames from 'classnames'; | |||
| import { useMemo } from 'react'; | |||
| import MarkdownContent from '@/pages/chat/markdown-content'; | |||
| import { getExtension, isPdf } from '@/utils/documentUtils'; | |||
| import { Avatar, Flex, List } from 'antd'; | |||
| import NewDocumentLink from '../new-document-link'; | |||
| import SvgIcon from '../svg-icon'; | |||
| import styles from './index.less'; | |||
| const MessageItem = ({ | |||
| item, | |||
| reference, | |||
| loading = false, | |||
| clickDocumentButton, | |||
| }: { | |||
| item: Message; | |||
| reference: IReference; | |||
| loading?: boolean; | |||
| clickDocumentButton: (documentId: string, chunk: IChunk) => void; | |||
| }) => { | |||
| const userInfo = useSelectUserInfo(); | |||
| const fileThumbnails = useSelectFileThumbnails(); | |||
| const getDocumentUrl = useGetDocumentUrl(); | |||
| const { t } = useTranslate('chat'); | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| const referenceDocumentList = useMemo(() => { | |||
| return reference?.doc_aggs ?? []; | |||
| }, [reference?.doc_aggs]); | |||
| const content = useMemo(() => { | |||
| let text = item.content; | |||
| if (text === '') { | |||
| text = t('searching'); | |||
| } | |||
| return loading ? text?.concat('~~2$$') : text; | |||
| }, [item.content, loading, t]); | |||
| 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}> | |||
| <MarkdownContent | |||
| content={content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| </div> | |||
| {isAssistant && referenceDocumentList.length > 0 && ( | |||
| <List | |||
| bordered | |||
| dataSource={referenceDocumentList} | |||
| renderItem={(item) => { | |||
| const fileThumbnail = fileThumbnails[item.doc_id]; | |||
| const fileExtension = getExtension(item.doc_name); | |||
| return ( | |||
| <List.Item> | |||
| <Flex gap={'small'} align="center"> | |||
| {fileThumbnail ? ( | |||
| <img | |||
| src={fileThumbnail} | |||
| className={styles.thumbnailImg} | |||
| ></img> | |||
| ) : ( | |||
| <SvgIcon | |||
| name={`file-icon/${fileExtension}`} | |||
| width={24} | |||
| ></SvgIcon> | |||
| )} | |||
| <NewDocumentLink | |||
| link={getDocumentUrl(item.doc_id)} | |||
| preventDefault={!isPdf(item.doc_name)} | |||
| > | |||
| {item.doc_name} | |||
| </NewDocumentLink> | |||
| </Flex> | |||
| </List.Item> | |||
| ); | |||
| }} | |||
| /> | |||
| )} | |||
| </Flex> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default MessageItem; | |||
| @@ -9,7 +9,14 @@ import { getAuthorization } from '@/utils/authorizationUtil'; | |||
| import { PaginationProps } from 'antd'; | |||
| import axios from 'axios'; | |||
| import { EventSourceParserStream } from 'eventsource-parser/stream'; | |||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | |||
| import { | |||
| ChangeEventHandler, | |||
| useCallback, | |||
| useEffect, | |||
| useMemo, | |||
| useRef, | |||
| useState, | |||
| } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { useDispatch } from 'umi'; | |||
| import { useSetModalState, useTranslate } from './commonHooks'; | |||
| @@ -196,3 +203,39 @@ export const useSendMessageWithSse = ( | |||
| return { send, answer, done }; | |||
| }; | |||
| //#region chat hooks | |||
| export const useScrollToBottom = (id?: string) => { | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| const scrollToBottom = useCallback(() => { | |||
| if (id) { | |||
| ref.current?.scrollIntoView({ behavior: 'instant' }); | |||
| } | |||
| }, [id]); | |||
| useEffect(() => { | |||
| scrollToBottom(); | |||
| }, [scrollToBottom]); | |||
| return ref; | |||
| }; | |||
| export const useHandleMessageInputChange = () => { | |||
| const [value, setValue] = useState(''); | |||
| const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => { | |||
| const value = e.target.value; | |||
| const nextValue = value.replaceAll('\\n', '\n').replaceAll('\\t', '\t'); | |||
| setValue(nextValue); | |||
| }; | |||
| return { | |||
| handleInputChange, | |||
| value, | |||
| setValue, | |||
| }; | |||
| }; | |||
| // #endregion | |||
| @@ -4,7 +4,7 @@ export type DSLComponents = Record<string, IOperator>; | |||
| export interface DSL { | |||
| components: DSLComponents; | |||
| history?: any[]; | |||
| history: any[]; | |||
| path?: string[]; | |||
| answer?: any[]; | |||
| graph?: IGraph; | |||
| @@ -4,7 +4,7 @@ import { | |||
| EdgeProps, | |||
| getBezierPath, | |||
| } from 'reactflow'; | |||
| import useStore from '../../store'; | |||
| import useGraphStore from '../../store'; | |||
| import { useMemo } from 'react'; | |||
| import styles from './index.less'; | |||
| @@ -21,7 +21,7 @@ export function ButtonEdge({ | |||
| markerEnd, | |||
| selected, | |||
| }: EdgeProps) { | |||
| const deleteEdgeById = useStore((state) => state.deleteEdgeById); | |||
| const deleteEdgeById = useGraphStore((state) => state.deleteEdgeById); | |||
| const [edgePath, labelX, labelY] = getBezierPath({ | |||
| sourceX, | |||
| sourceY, | |||
| @@ -0,0 +1,104 @@ | |||
| import MessageItem from '@/components/message-item'; | |||
| import DocumentPreviewer from '@/components/pdf-previewer'; | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { | |||
| useClickDrawer, | |||
| useFetchConversationOnMount, | |||
| useGetFileIcon, | |||
| useGetSendButtonDisabled, | |||
| useSelectConversationLoading, | |||
| useSendMessage, | |||
| } from '@/pages/chat/hooks'; | |||
| import { buildMessageItemReference } from '@/pages/chat/utils'; | |||
| import { Button, Drawer, Flex, Input, Spin } from 'antd'; | |||
| import styles from './index.less'; | |||
| const FlowChatBox = () => { | |||
| const { | |||
| ref, | |||
| currentConversation: conversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| } = useFetchConversationOnMount(); | |||
| const { | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| value, | |||
| loading: sendLoading, | |||
| } = useSendMessage( | |||
| conversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| ); | |||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||
| useClickDrawer(); | |||
| const disabled = useGetSendButtonDisabled(); | |||
| useGetFileIcon(); | |||
| const loading = useSelectConversationLoading(); | |||
| const { t } = useTranslate('chat'); | |||
| return ( | |||
| <> | |||
| <Flex flex={1} className={styles.chatContainer} vertical> | |||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||
| <div> | |||
| <Spin spinning={loading}> | |||
| {conversation?.message?.map((message, i) => { | |||
| return ( | |||
| <MessageItem | |||
| loading={ | |||
| message.role === MessageType.Assistant && | |||
| sendLoading && | |||
| conversation?.message.length - 1 === i | |||
| } | |||
| key={message.id} | |||
| item={message} | |||
| reference={buildMessageItemReference(conversation, message)} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MessageItem> | |||
| ); | |||
| })} | |||
| </Spin> | |||
| </div> | |||
| <div ref={ref} /> | |||
| </Flex> | |||
| <Input | |||
| size="large" | |||
| placeholder={t('sendPlaceholder')} | |||
| value={value} | |||
| disabled={disabled} | |||
| suffix={ | |||
| <Button | |||
| type="primary" | |||
| onClick={handlePressEnter} | |||
| loading={sendLoading} | |||
| disabled={disabled} | |||
| > | |||
| {t('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 FlowChatBox; | |||
| @@ -0,0 +1,206 @@ | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { useFetchFlow } from '@/hooks/flow-hooks'; | |||
| import { | |||
| useHandleMessageInputChange, | |||
| // useScrollToBottom, | |||
| useSendMessageWithSse, | |||
| } from '@/hooks/logicHooks'; | |||
| import { IAnswer } from '@/interfaces/database/chat'; | |||
| import { IMessage } from '@/pages/chat/interface'; | |||
| import omit from 'lodash/omit'; | |||
| import { useCallback, useEffect, useState } from 'react'; | |||
| import { useParams } from 'umi'; | |||
| import { v4 as uuid } from 'uuid'; | |||
| import { Operator } from '../constant'; | |||
| import useGraphStore from '../store'; | |||
| export const useSelectCurrentConversation = () => { | |||
| const { id: id } = useParams(); | |||
| const findNodeByName = useGraphStore((state) => state.findNodeByName); | |||
| const [currentMessages, setCurrentMessages] = useState<IMessage[]>([]); | |||
| const { data: flowDetail } = useFetchFlow(); | |||
| const messages = flowDetail.dsl.history; | |||
| const prologue = findNodeByName(Operator.Begin)?.data?.form?.prologue; | |||
| const addNewestQuestion = useCallback( | |||
| (message: string, answer: string = '') => { | |||
| setCurrentMessages((pre) => { | |||
| return [ | |||
| ...pre, | |||
| { | |||
| role: MessageType.User, | |||
| content: message, | |||
| id: uuid(), | |||
| }, | |||
| { | |||
| role: MessageType.Assistant, | |||
| content: answer, | |||
| id: uuid(), | |||
| }, | |||
| ]; | |||
| }); | |||
| }, | |||
| [], | |||
| ); | |||
| const addNewestAnswer = useCallback( | |||
| (answer: IAnswer) => { | |||
| setCurrentMessages((pre) => { | |||
| const latestMessage = currentMessages?.at(-1); | |||
| if (latestMessage) { | |||
| return [ | |||
| ...pre.slice(0, -1), | |||
| { | |||
| ...latestMessage, | |||
| content: answer.answer, | |||
| reference: answer.reference, | |||
| }, | |||
| ]; | |||
| } | |||
| return pre; | |||
| }); | |||
| }, | |||
| [currentMessages], | |||
| ); | |||
| const removeLatestMessage = useCallback(() => { | |||
| setCurrentMessages((pre) => { | |||
| const nextMessages = pre?.slice(0, -2) ?? []; | |||
| return [...pre, ...nextMessages]; | |||
| }); | |||
| }, []); | |||
| const addPrologue = useCallback(() => { | |||
| if (id === '') { | |||
| const nextMessage = { | |||
| role: MessageType.Assistant, | |||
| content: prologue, | |||
| id: uuid(), | |||
| } as IMessage; | |||
| setCurrentMessages({ | |||
| id: '', | |||
| reference: [], | |||
| message: [nextMessage], | |||
| } as any); | |||
| } | |||
| }, [id, prologue]); | |||
| useEffect(() => { | |||
| addPrologue(); | |||
| }, [addPrologue]); | |||
| useEffect(() => { | |||
| if (id) { | |||
| setCurrentMessages(messages); | |||
| } | |||
| }, [messages, id]); | |||
| return { | |||
| currentConversation: currentMessages, | |||
| addNewestQuestion, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| }; | |||
| }; | |||
| // export const useFetchConversationOnMount = () => { | |||
| // const { conversationId } = useGetChatSearchParams(); | |||
| // const fetchConversation = useFetchConversation(); | |||
| // const { | |||
| // currentConversation, | |||
| // addNewestQuestion, | |||
| // removeLatestMessage, | |||
| // addNewestAnswer, | |||
| // } = useSelectCurrentConversation(); | |||
| // const ref = useScrollToBottom(currentConversation); | |||
| // const fetchConversationOnMount = useCallback(() => { | |||
| // if (isConversationIdExist(conversationId)) { | |||
| // fetchConversation(conversationId); | |||
| // } | |||
| // }, [fetchConversation, conversationId]); | |||
| // useEffect(() => { | |||
| // fetchConversationOnMount(); | |||
| // }, [fetchConversationOnMount]); | |||
| // return { | |||
| // currentConversation, | |||
| // addNewestQuestion, | |||
| // ref, | |||
| // removeLatestMessage, | |||
| // addNewestAnswer, | |||
| // }; | |||
| // }; | |||
| export const useSendMessage = ( | |||
| conversation: any, | |||
| addNewestQuestion: (message: string, answer?: string) => void, | |||
| removeLatestMessage: () => void, | |||
| addNewestAnswer: (answer: IAnswer) => void, | |||
| ) => { | |||
| const { id: conversationId } = useParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const { send, answer, done } = useSendMessageWithSse(); | |||
| const sendMessage = useCallback( | |||
| async (message: string, id?: string) => { | |||
| const res: Response | undefined = await send({ | |||
| conversation_id: id ?? conversationId, | |||
| messages: [ | |||
| ...(conversation?.message ?? []).map((x: IMessage) => omit(x, 'id')), | |||
| { | |||
| role: MessageType.User, | |||
| content: message, | |||
| }, | |||
| ], | |||
| }); | |||
| if (res?.status !== 200) { | |||
| // cancel loading | |||
| setValue(message); | |||
| removeLatestMessage(); | |||
| } | |||
| }, | |||
| [ | |||
| conversation?.message, | |||
| conversationId, | |||
| removeLatestMessage, | |||
| setValue, | |||
| send, | |||
| ], | |||
| ); | |||
| const handleSendMessage = useCallback( | |||
| async (message: string) => { | |||
| sendMessage(message); | |||
| }, | |||
| [sendMessage], | |||
| ); | |||
| useEffect(() => { | |||
| if (answer.answer) { | |||
| addNewestAnswer(answer); | |||
| } | |||
| }, [answer, addNewestAnswer]); | |||
| const handlePressEnter = useCallback(() => { | |||
| if (done) { | |||
| setValue(''); | |||
| handleSendMessage(value.trim()); | |||
| } | |||
| addNewestQuestion(value); | |||
| }, [addNewestQuestion, handleSendMessage, done, setValue, value]); | |||
| return { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading: !done, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,7 @@ | |||
| .chatContainer { | |||
| padding: 0 0 24px 24px; | |||
| .messageContainer { | |||
| overflow-y: auto; | |||
| padding-right: 24px; | |||
| } | |||
| } | |||
| @@ -18,7 +18,7 @@ import { Node, Position, ReactFlowInstance } from 'reactflow'; | |||
| import { v4 as uuidv4 } from 'uuid'; | |||
| // import { shallow } from 'zustand/shallow'; | |||
| import { useParams } from 'umi'; | |||
| import useStore, { RFState } from './store'; | |||
| import useGraphStore, { RFState } from './store'; | |||
| import { buildDslComponentsByGraph } from './utils'; | |||
| const selector = (state: RFState) => ({ | |||
| @@ -34,7 +34,7 @@ const selector = (state: RFState) => ({ | |||
| export const useSelectCanvasData = () => { | |||
| // return useStore(useShallow(selector)); // throw error | |||
| // return useStore(selector, shallow); | |||
| return useStore(selector); | |||
| return useGraphStore(selector); | |||
| }; | |||
| export const useHandleDrag = () => { | |||
| @@ -50,7 +50,7 @@ export const useHandleDrag = () => { | |||
| }; | |||
| export const useHandleDrop = () => { | |||
| const addNode = useStore((state) => state.addNode); | |||
| const addNode = useGraphStore((state) => state.addNode); | |||
| const [reactFlowInstance, setReactFlowInstance] = | |||
| useState<ReactFlowInstance<any, any>>(); | |||
| @@ -124,7 +124,7 @@ export const useShowDrawer = () => { | |||
| }; | |||
| export const useHandleKeyUp = () => { | |||
| const deleteEdge = useStore((state) => state.deleteEdge); | |||
| const deleteEdge = useGraphStore((state) => state.deleteEdge); | |||
| const handleKeyUp: KeyboardEventHandler = useCallback( | |||
| (e) => { | |||
| if (e.code === 'Delete') { | |||
| @@ -141,7 +141,7 @@ export const useSaveGraph = () => { | |||
| const { data } = useFetchFlow(); | |||
| const { setFlow } = useSetFlow(); | |||
| const { id } = useParams(); | |||
| const { nodes, edges } = useStore((state) => state); | |||
| const { nodes, edges } = useGraphStore((state) => state); | |||
| const saveGraph = useCallback(() => { | |||
| const dslComponents = buildDslComponentsByGraph(nodes, edges); | |||
| setFlow({ | |||
| @@ -155,7 +155,7 @@ export const useSaveGraph = () => { | |||
| }; | |||
| export const useHandleFormValuesChange = (id?: string) => { | |||
| const updateNodeForm = useStore((state) => state.updateNodeForm); | |||
| const updateNodeForm = useGraphStore((state) => state.updateNodeForm); | |||
| const handleValuesChange = useCallback( | |||
| (changedValues: any, values: any) => { | |||
| console.info(changedValues, values); | |||
| @@ -170,7 +170,7 @@ export const useHandleFormValuesChange = (id?: string) => { | |||
| }; | |||
| const useSetGraphInfo = () => { | |||
| const { setEdges, setNodes } = useStore((state) => state); | |||
| const { setEdges, setNodes } = useGraphStore((state) => state); | |||
| const setGraphInfo = useCallback( | |||
| ({ nodes = [], edges = [] }: IGraph) => { | |||
| if (nodes.length && edges.length) { | |||
| @@ -205,7 +205,7 @@ export const useRunGraph = () => { | |||
| const { data } = useFetchFlow(); | |||
| const { runFlow } = useRunFlow(); | |||
| const { id } = useParams(); | |||
| const { nodes, edges } = useStore((state) => state); | |||
| const { nodes, edges } = useGraphStore((state) => state); | |||
| const runGraph = useCallback(() => { | |||
| const dslComponents = buildDslComponentsByGraph(nodes, edges); | |||
| runFlow({ | |||
| @@ -16,6 +16,7 @@ import { | |||
| } from 'reactflow'; | |||
| import { create } from 'zustand'; | |||
| import { devtools } from 'zustand/middleware'; | |||
| import { Operator } from './constant'; | |||
| import { NodeData } from './interface'; | |||
| export type RFState = { | |||
| @@ -33,10 +34,11 @@ export type RFState = { | |||
| addNode: (nodes: Node) => void; | |||
| deleteEdge: () => void; | |||
| deleteEdgeById: (id: string) => void; | |||
| findNodeByName: (operatorName: Operator) => Node | undefined; | |||
| }; | |||
| // this is our useStore hook that we can use in our components to get parts of the store and call actions | |||
| const useStore = create<RFState>()( | |||
| const useGraphStore = create<RFState>()( | |||
| devtools((set, get) => ({ | |||
| nodes: [] as Node[], | |||
| edges: [] as Edge[], | |||
| @@ -86,6 +88,9 @@ const useStore = create<RFState>()( | |||
| edges: edges.filter((edge) => edge.id !== id), | |||
| }); | |||
| }, | |||
| findNodeByName: (name: Operator) => { | |||
| return get().nodes.find((x) => x.data.label === name); | |||
| }, | |||
| updateNodeForm: (nodeId: string, values: any) => { | |||
| set({ | |||
| nodes: get().nodes.map((node) => { | |||
| @@ -100,4 +105,4 @@ const useStore = create<RFState>()( | |||
| })), | |||
| ); | |||
| export default useStore; | |||
| export default useGraphStore; | |||