### What problem does this PR solve? Feat: Interrupt streaming #6515 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.18.0
| UploadProps, | UploadProps, | ||||
| } from 'antd'; | } from 'antd'; | ||||
| import get from 'lodash/get'; | import get from 'lodash/get'; | ||||
| import { CircleStop } from 'lucide-react'; | |||||
| import { | import { | ||||
| ChangeEventHandler, | ChangeEventHandler, | ||||
| memo, | memo, | ||||
| isShared?: boolean; | isShared?: boolean; | ||||
| showUploadIcon?: boolean; | showUploadIcon?: boolean; | ||||
| createConversationBeforeUploadDocument?(message: string): Promise<any>; | createConversationBeforeUploadDocument?(message: string): Promise<any>; | ||||
| stopOutputMessage?(): void; | |||||
| } | } | ||||
| const getBase64 = (file: FileType): Promise<string> => | const getBase64 = (file: FileType): Promise<string> => | ||||
| showUploadIcon = true, | showUploadIcon = true, | ||||
| createConversationBeforeUploadDocument, | createConversationBeforeUploadDocument, | ||||
| uploadMethod = 'upload_and_parse', | uploadMethod = 'upload_and_parse', | ||||
| stopOutputMessage, | |||||
| }: IProps) => { | }: IProps) => { | ||||
| const { t } = useTranslate('chat'); | const { t } = useTranslate('chat'); | ||||
| const { removeDocument } = useRemoveNextDocument(); | const { removeDocument } = useRemoveNextDocument(); | ||||
| event.preventDefault(); | event.preventDefault(); | ||||
| handlePressEnter(); | handlePressEnter(); | ||||
| }, | }, | ||||
| [fileList, onPressEnter, isUploadingFile], | |||||
| [sendDisabled, isUploadingFile, sendLoading, handlePressEnter], | |||||
| ); | ); | ||||
| const handlePressEnter = useCallback(async () => { | const handlePressEnter = useCallback(async () => { | ||||
| [removeDocument, deleteDocument, isShared], | [removeDocument, deleteDocument, isShared], | ||||
| ); | ); | ||||
| const handleStopOutputMessage = useCallback(() => { | |||||
| stopOutputMessage?.(); | |||||
| }, [stopOutputMessage]); | |||||
| const getDocumentInfoById = useCallback( | const getDocumentInfoById = useCallback( | ||||
| (id: string) => { | (id: string) => { | ||||
| return documentInfos.find((x) => x.id === id); | return documentInfos.find((x) => x.id === id); | ||||
| </Button> | </Button> | ||||
| </Upload> | </Upload> | ||||
| )} | )} | ||||
| <Button | |||||
| type="primary" | |||||
| onClick={handlePressEnter} | |||||
| loading={sendLoading} | |||||
| disabled={sendDisabled || isUploadingFile || sendLoading} | |||||
| > | |||||
| <SendOutlined /> | |||||
| </Button> | |||||
| {sendLoading ? ( | |||||
| <Button onClick={handleStopOutputMessage}> | |||||
| <CircleStop /> | |||||
| </Button> | |||||
| ) : ( | |||||
| <Button | |||||
| type="primary" | |||||
| onClick={handlePressEnter} | |||||
| loading={sendLoading} | |||||
| disabled={sendDisabled || isUploadingFile || sendLoading} | |||||
| > | |||||
| <SendOutlined /> | |||||
| </Button> | |||||
| )} | |||||
| </Flex> | </Flex> | ||||
| </Flex> | </Flex> | ||||
| </Flex> | </Flex> |
| const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); | const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); | ||||
| const [done, setDone] = useState(true); | const [done, setDone] = useState(true); | ||||
| const timer = useRef<any>(); | const timer = useRef<any>(); | ||||
| const sseRef = useRef<AbortController>(); | |||||
| const initializeSseRef = useCallback(() => { | |||||
| sseRef.current = new AbortController(); | |||||
| }, []); | |||||
| const resetAnswer = useCallback(() => { | const resetAnswer = useCallback(() => { | ||||
| if (timer.current) { | if (timer.current) { | ||||
| body: any, | body: any, | ||||
| controller?: AbortController, | controller?: AbortController, | ||||
| ): Promise<{ response: Response; data: ResponseType } | undefined> => { | ): Promise<{ response: Response; data: ResponseType } | undefined> => { | ||||
| initializeSseRef(); | |||||
| try { | try { | ||||
| setDone(false); | setDone(false); | ||||
| const response = await fetch(url, { | const response = await fetch(url, { | ||||
| 'Content-Type': 'application/json', | 'Content-Type': 'application/json', | ||||
| }, | }, | ||||
| body: JSON.stringify(body), | body: JSON.stringify(body), | ||||
| signal: controller?.signal, | |||||
| signal: controller?.signal || sseRef.current?.signal, | |||||
| }); | }); | ||||
| const res = response.clone().json(); | const res = response.clone().json(); | ||||
| console.warn(e); | console.warn(e); | ||||
| } | } | ||||
| }, | }, | ||||
| [url, resetAnswer], | |||||
| [initializeSseRef, url, resetAnswer], | |||||
| ); | ); | ||||
| return { send, answer, done, setDone, resetAnswer }; | |||||
| const stopOutputMessage = useCallback(() => { | |||||
| sseRef.current?.abort(); | |||||
| }, []); | |||||
| return { send, answer, done, setDone, resetAnswer, stopOutputMessage }; | |||||
| }; | }; | ||||
| export const useSpeechWithSse = (url: string = api.tts) => { | export const useSpeechWithSse = (url: string = api.tts) => { |
| handlePressEnter, | handlePressEnter, | ||||
| regenerateMessage, | regenerateMessage, | ||||
| removeMessageById, | removeMessageById, | ||||
| stopOutputMessage, | |||||
| } = useSendNextMessage(controller); | } = useSendNextMessage(controller); | ||||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | ||||
| createConversationBeforeUploadDocument={ | createConversationBeforeUploadDocument={ | ||||
| createConversationBeforeUploadDocument | createConversationBeforeUploadDocument | ||||
| } | } | ||||
| stopOutputMessage={stopOutputMessage} | |||||
| ></MessageInput> | ></MessageInput> | ||||
| </Flex> | </Flex> | ||||
| <PdfDrawer | <PdfDrawer |
| const { setConversationIsNew, getConversationIsNew } = | const { setConversationIsNew, getConversationIsNew } = | ||||
| useSetChatRouteParams(); | useSetChatRouteParams(); | ||||
| const stopOutputMessage = useCallback(() => { | |||||
| controller.abort(); | |||||
| }, [controller]); | |||||
| const sendMessage = useCallback( | const sendMessage = useCallback( | ||||
| async ({ | async ({ | ||||
| message, | message, | ||||
| ref, | ref, | ||||
| derivedMessages, | derivedMessages, | ||||
| removeMessageById, | removeMessageById, | ||||
| stopOutputMessage, | |||||
| }; | }; | ||||
| }; | }; | ||||
| ref, | ref, | ||||
| derivedMessages, | derivedMessages, | ||||
| hasError, | hasError, | ||||
| stopOutputMessage, | |||||
| } = useSendSharedMessage(); | } = useSendSharedMessage(); | ||||
| const sendDisabled = useSendButtonDisabled(value); | const sendDisabled = useSendButtonDisabled(value); | ||||
| sendLoading={sendLoading} | sendLoading={sendLoading} | ||||
| uploadMethod="external_upload_and_parse" | uploadMethod="external_upload_and_parse" | ||||
| showUploadIcon={false} | showUploadIcon={false} | ||||
| stopOutputMessage={stopOutputMessage} | |||||
| ></MessageInput> | ></MessageInput> | ||||
| </Flex> | </Flex> | ||||
| {visible && ( | {visible && ( |
| const { createSharedConversation: setConversation } = | const { createSharedConversation: setConversation } = | ||||
| useCreateNextSharedConversation(); | useCreateNextSharedConversation(); | ||||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | ||||
| const { send, answer, done } = useSendMessageWithSse( | |||||
| const { send, answer, done, stopOutputMessage } = useSendMessageWithSse( | |||||
| `/api/v1/${from === SharedFrom.Agent ? 'agentbots' : 'chatbots'}/${conversationId}/completions`, | `/api/v1/${from === SharedFrom.Agent ? 'agentbots' : 'chatbots'}/${conversationId}/completions`, | ||||
| ); | ); | ||||
| const { | const { | ||||
| loading: false, | loading: false, | ||||
| derivedMessages, | derivedMessages, | ||||
| hasError, | hasError, | ||||
| stopOutputMessage, | |||||
| }; | }; | ||||
| }; | }; |
| ref, | ref, | ||||
| derivedMessages, | derivedMessages, | ||||
| reference, | reference, | ||||
| stopOutputMessage, | |||||
| } = useSendNextMessage(); | } = useSendNextMessage(); | ||||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | ||||
| conversationId="" | conversationId="" | ||||
| onPressEnter={handlePressEnter} | onPressEnter={handlePressEnter} | ||||
| onInputChange={handleInputChange} | onInputChange={handleInputChange} | ||||
| stopOutputMessage={stopOutputMessage} | |||||
| /> | /> | ||||
| </Flex> | </Flex> | ||||
| <PdfDrawer | <PdfDrawer |
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | ||||
| const { refetch } = useFetchFlow(); | const { refetch } = useFetchFlow(); | ||||
| const { send, answer, done } = useSendMessageWithSse(api.runCanvas); | |||||
| const { send, answer, done, stopOutputMessage } = useSendMessageWithSse( | |||||
| api.runCanvas, | |||||
| ); | |||||
| const sendMessage = useCallback( | const sendMessage = useCallback( | ||||
| async ({ message }: { message: Message; messages?: Message[] }) => { | async ({ message }: { message: Message; messages?: Message[] }) => { | ||||
| derivedMessages, | derivedMessages, | ||||
| ref, | ref, | ||||
| removeMessageById, | removeMessageById, | ||||
| stopOutputMessage, | |||||
| }; | }; | ||||
| }; | }; |
| } from 'react'; | } from 'react'; | ||||
| export const useSendQuestion = (kbIds: string[]) => { | export const useSendQuestion = (kbIds: string[]) => { | ||||
| const { send, answer, done } = useSendMessageWithSse(api.ask); | |||||
| const { send, answer, done, stopOutputMessage } = useSendMessageWithSse( | |||||
| api.ask, | |||||
| ); | |||||
| const { testChunk, loading } = useTestChunkRetrieval(); | const { testChunk, loading } = useTestChunkRetrieval(); | ||||
| const [sendingLoading, setSendingLoading] = useState(false); | const [sendingLoading, setSendingLoading] = useState(false); | ||||
| const [currentAnswer, setCurrentAnswer] = useState({} as IAnswer); | const [currentAnswer, setCurrentAnswer] = useState({} as IAnswer); | ||||
| isFirstRender, | isFirstRender, | ||||
| selectedDocumentIds, | selectedDocumentIds, | ||||
| isSearchStrEmpty: isEmpty(trim(searchStr)), | isSearchStrEmpty: isEmpty(trim(searchStr)), | ||||
| stopOutputMessage, | |||||
| }; | }; | ||||
| }; | }; | ||||
| .input(); | .input(); | ||||
| } | } | ||||
| .searchInput { | |||||
| :global(.ant-input-search-button) { | |||||
| display: none; | |||||
| } | |||||
| } | |||||
| .appIcon { | .appIcon { | ||||
| display: inline-block; | display: inline-block; | ||||
| vertical-align: middle; | vertical-align: middle; |
| import { useGetPaginationWithRouter } from '@/hooks/logic-hooks'; | import { useGetPaginationWithRouter } from '@/hooks/logic-hooks'; | ||||
| import { IReference } from '@/interfaces/database/chat'; | import { IReference } from '@/interfaces/database/chat'; | ||||
| import { | import { | ||||
| Button, | |||||
| Card, | Card, | ||||
| Divider, | Divider, | ||||
| Flex, | Flex, | ||||
| Tag, | Tag, | ||||
| Tooltip, | Tooltip, | ||||
| } from 'antd'; | } from 'antd'; | ||||
| import classNames from 'classnames'; | |||||
| import DOMPurify from 'dompurify'; | import DOMPurify from 'dompurify'; | ||||
| import { isEmpty } from 'lodash'; | import { isEmpty } from 'lodash'; | ||||
| import { useMemo, useState } from 'react'; | |||||
| import { CircleStop, SendHorizontal } from 'lucide-react'; | |||||
| import { useCallback, useMemo, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import MarkdownContent from '../chat/markdown-content'; | import MarkdownContent from '../chat/markdown-content'; | ||||
| import { useSendQuestion, useShowMindMapDrawer } from './hooks'; | import { useSendQuestion, useShowMindMapDrawer } from './hooks'; | ||||
| isFirstRender, | isFirstRender, | ||||
| selectedDocumentIds, | selectedDocumentIds, | ||||
| isSearchStrEmpty, | isSearchStrEmpty, | ||||
| stopOutputMessage, | |||||
| } = useSendQuestion(checkedWithoutEmbeddingIdList); | } = useSendQuestion(checkedWithoutEmbeddingIdList); | ||||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | ||||
| useClickDrawer(); | useClickDrawer(); | ||||
| handleTestChunk(selectedDocumentIds, pageNumber, pageSize); | handleTestChunk(selectedDocumentIds, pageNumber, pageSize); | ||||
| }; | }; | ||||
| const handleSearch = useCallback(() => { | |||||
| sendQuestion(searchStr); | |||||
| }, [searchStr, sendQuestion]); | |||||
| const InputSearch = ( | const InputSearch = ( | ||||
| <Search | <Search | ||||
| value={searchStr} | value={searchStr} | ||||
| onChange={handleSearchStrChange} | onChange={handleSearchStrChange} | ||||
| placeholder={t('header.search')} | placeholder={t('header.search')} | ||||
| allowClear | allowClear | ||||
| enterButton | |||||
| addonAfter={ | |||||
| sendingLoading ? ( | |||||
| <Button onClick={stopOutputMessage}> | |||||
| <CircleStop /> | |||||
| </Button> | |||||
| ) : ( | |||||
| <Button onClick={handleSearch}> | |||||
| <SendHorizontal className="size-5 text-blue-500" /> | |||||
| </Button> | |||||
| ) | |||||
| } | |||||
| onSearch={sendQuestion} | onSearch={sendQuestion} | ||||
| size="large" | size="large" | ||||
| loading={sendingLoading} | loading={sendingLoading} | ||||
| disabled={checkedWithoutEmbeddingIdList.length === 0} | disabled={checkedWithoutEmbeddingIdList.length === 0} | ||||
| className={isFirstRender ? styles.globalInput : styles.partialInput} | |||||
| className={classNames( | |||||
| styles.searchInput, | |||||
| isFirstRender ? styles.globalInput : styles.partialInput, | |||||
| )} | |||||
| /> | /> | ||||
| ); | ); | ||||