From 2a9ac7dcc7f71caa037f3686901629bdca7770de Mon Sep 17 00:00:00 2001 From: bill Date: Thu, 6 Jun 2024 19:26:36 +0800 Subject: [PATCH] feat: add FlowChatBox --- web/src/components/message-item/index.less | 40 ++++ web/src/components/message-item/index.tsx | 128 +++++++++++++ web/src/hooks/logicHooks.ts | 45 ++++- web/src/interfaces/database/flow.ts | 2 +- web/src/pages/flow/canvas/edge/index.tsx | 4 +- web/src/pages/flow/chat/box.tsx | 104 +++++++++++ web/src/pages/flow/chat/hooks.ts | 206 +++++++++++++++++++++ web/src/pages/flow/chat/index.less | 7 + web/src/pages/flow/hooks.ts | 16 +- web/src/pages/flow/store.ts | 9 +- 10 files changed, 547 insertions(+), 14 deletions(-) create mode 100644 web/src/components/message-item/index.less create mode 100644 web/src/components/message-item/index.tsx create mode 100644 web/src/pages/flow/chat/box.tsx create mode 100644 web/src/pages/flow/chat/hooks.ts create mode 100644 web/src/pages/flow/chat/index.less diff --git a/web/src/components/message-item/index.less b/web/src/components/message-item/index.less new file mode 100644 index 0000000000..4e6c3b304f --- /dev/null +++ b/web/src/components/message-item/index.less @@ -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; +} diff --git a/web/src/components/message-item/index.tsx b/web/src/components/message-item/index.tsx new file mode 100644 index 0000000000..a5064b391e --- /dev/null +++ b/web/src/components/message-item/index.tsx @@ -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 ( +
+
+
+ {item.role === MessageType.User ? ( + + ) : ( + + )} + + {isAssistant ? '' : userInfo.nickname} +
+ +
+ {isAssistant && referenceDocumentList.length > 0 && ( + { + const fileThumbnail = fileThumbnails[item.doc_id]; + const fileExtension = getExtension(item.doc_name); + return ( + + + {fileThumbnail ? ( + + ) : ( + + )} + + + {item.doc_name} + + + + ); + }} + /> + )} +
+
+
+
+ ); +}; + +export default MessageItem; diff --git a/web/src/hooks/logicHooks.ts b/web/src/hooks/logicHooks.ts index 0b888839e7..b6c2f0e072 100644 --- a/web/src/hooks/logicHooks.ts +++ b/web/src/hooks/logicHooks.ts @@ -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(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 = (e) => { + const value = e.target.value; + const nextValue = value.replaceAll('\\n', '\n').replaceAll('\\t', '\t'); + setValue(nextValue); + }; + + return { + handleInputChange, + value, + setValue, + }; +}; + +// #endregion diff --git a/web/src/interfaces/database/flow.ts b/web/src/interfaces/database/flow.ts index b9d57b91de..517f670136 100644 --- a/web/src/interfaces/database/flow.ts +++ b/web/src/interfaces/database/flow.ts @@ -4,7 +4,7 @@ export type DSLComponents = Record; export interface DSL { components: DSLComponents; - history?: any[]; + history: any[]; path?: string[]; answer?: any[]; graph?: IGraph; diff --git a/web/src/pages/flow/canvas/edge/index.tsx b/web/src/pages/flow/canvas/edge/index.tsx index 4aa123b44a..614888b644 100644 --- a/web/src/pages/flow/canvas/edge/index.tsx +++ b/web/src/pages/flow/canvas/edge/index.tsx @@ -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, diff --git a/web/src/pages/flow/chat/box.tsx b/web/src/pages/flow/chat/box.tsx new file mode 100644 index 0000000000..38da9550dd --- /dev/null +++ b/web/src/pages/flow/chat/box.tsx @@ -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 ( + <> + + +
+ + {conversation?.message?.map((message, i) => { + return ( + + ); + })} + +
+
+ + + {t('send')} + + } + onPressEnter={handlePressEnter} + onChange={handleInputChange} + /> + + + + + + ); +}; + +export default FlowChatBox; diff --git a/web/src/pages/flow/chat/hooks.ts b/web/src/pages/flow/chat/hooks.ts new file mode 100644 index 0000000000..f02c3372ec --- /dev/null +++ b/web/src/pages/flow/chat/hooks.ts @@ -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([]); + + 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, + }; +}; diff --git a/web/src/pages/flow/chat/index.less b/web/src/pages/flow/chat/index.less new file mode 100644 index 0000000000..8430b1ef64 --- /dev/null +++ b/web/src/pages/flow/chat/index.less @@ -0,0 +1,7 @@ +.chatContainer { + padding: 0 0 24px 24px; + .messageContainer { + overflow-y: auto; + padding-right: 24px; + } +} diff --git a/web/src/pages/flow/hooks.ts b/web/src/pages/flow/hooks.ts index 6dd6d51ace..81152d0ae2 100644 --- a/web/src/pages/flow/hooks.ts +++ b/web/src/pages/flow/hooks.ts @@ -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>(); @@ -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({ diff --git a/web/src/pages/flow/store.ts b/web/src/pages/flow/store.ts index cb63d638e1..8f956e551a 100644 --- a/web/src/pages/flow/store.ts +++ b/web/src/pages/flow/store.ts @@ -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()( +const useGraphStore = create()( devtools((set, get) => ({ nodes: [] as Node[], edges: [] as Edge[], @@ -86,6 +88,9 @@ const useStore = create()( 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()( })), ); -export default useStore; +export default useGraphStore;