### What problem does this PR solve? feat: Support for conversational streaming #709 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.6.0
| @@ -0,0 +1 @@ | |||
| PORT=9222 | |||
| @@ -15,6 +15,7 @@ | |||
| "axios": "^1.6.3", | |||
| "classnames": "^2.5.1", | |||
| "dayjs": "^1.11.10", | |||
| "eventsource-parser": "^1.1.2", | |||
| "i18next": "^23.7.16", | |||
| "js-base64": "^3.7.5", | |||
| "jsencrypt": "^3.3.2", | |||
| @@ -10206,6 +10207,14 @@ | |||
| "node": ">=0.8.x" | |||
| } | |||
| }, | |||
| "node_modules/eventsource-parser": { | |||
| "version": "1.1.2", | |||
| "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-1.1.2.tgz", | |||
| "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", | |||
| "engines": { | |||
| "node": ">=14.18" | |||
| } | |||
| }, | |||
| "node_modules/evp_bytestokey": { | |||
| "version": "1.0.3", | |||
| "resolved": "https://registry.npmmirror.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", | |||
| @@ -3,7 +3,7 @@ | |||
| "author": "zhaofengchao <13723060510@163.com>", | |||
| "scripts": { | |||
| "build": "umi build", | |||
| "dev": "cross-env PORT=9200 UMI_DEV_SERVER_COMPRESS=none umi dev", | |||
| "dev": "cross-env UMI_DEV_SERVER_COMPRESS=none umi dev", | |||
| "postinstall": "umi setup", | |||
| "lint": "umi lint --eslint-only", | |||
| "setup": "umi setup", | |||
| @@ -19,6 +19,7 @@ | |||
| "axios": "^1.6.3", | |||
| "classnames": "^2.5.1", | |||
| "dayjs": "^1.11.10", | |||
| "eventsource-parser": "^1.1.2", | |||
| "i18next": "^23.7.16", | |||
| "js-base64": "^3.7.5", | |||
| "jsencrypt": "^3.3.2", | |||
| @@ -18,7 +18,7 @@ const NewDocumentLink = ({ | |||
| onClick={!preventDefault ? undefined : (e) => e.preventDefault()} | |||
| href={link} | |||
| rel="noreferrer" | |||
| style={{ color }} | |||
| style={{ color, wordBreak: 'break-all' }} | |||
| > | |||
| {children} | |||
| </a> | |||
| @@ -154,6 +154,9 @@ export const useRemoveConversation = () => { | |||
| return removeConversation; | |||
| }; | |||
| /* | |||
| @deprecated | |||
| */ | |||
| export const useCompleteConversation = () => { | |||
| const dispatch = useDispatch(); | |||
| @@ -283,20 +286,4 @@ export const useFetchSharedConversation = () => { | |||
| return fetchSharedConversation; | |||
| }; | |||
| export const useCompleteSharedConversation = () => { | |||
| const dispatch = useDispatch(); | |||
| const completeSharedConversation = useCallback( | |||
| (payload: any) => { | |||
| return dispatch<any>({ | |||
| type: 'chatModel/completeExternalConversation', | |||
| payload: payload, | |||
| }); | |||
| }, | |||
| [dispatch], | |||
| ); | |||
| return completeSharedConversation; | |||
| }; | |||
| //#endregion | |||
| @@ -1,13 +1,14 @@ | |||
| import { Authorization } from '@/constants/authorization'; | |||
| import { LanguageTranslationMap } from '@/constants/common'; | |||
| import { Pagination } from '@/interfaces/common'; | |||
| import { IAnswer } from '@/interfaces/database/chat'; | |||
| import { IKnowledgeFile } from '@/interfaces/database/knowledge'; | |||
| import { IChangeParserConfigRequestBody } from '@/interfaces/request/document'; | |||
| import api from '@/utils/api'; | |||
| import authorizationUtil from '@/utils/authorizationUtil'; | |||
| import { getSearchValue } from '@/utils/commonUtil'; | |||
| 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 { useTranslation } from 'react-i18next'; | |||
| import { useDispatch } from 'umi'; | |||
| @@ -138,62 +139,60 @@ export const useFetchAppConf = () => { | |||
| return appConf; | |||
| }; | |||
| export const useConnectWithSse = (url: string) => { | |||
| const [content, setContent] = useState<string>(''); | |||
| const connect = useCallback(() => { | |||
| const source = new EventSource( | |||
| url || '/sse/createSseEmitter?clientId=123456', | |||
| ); | |||
| source.onopen = function () { | |||
| console.log('Connection to the server was opened.'); | |||
| }; | |||
| source.onmessage = function (event: any) { | |||
| setContent(event.data); | |||
| }; | |||
| source.onerror = function (error) { | |||
| console.error('Error occurred:', error); | |||
| }; | |||
| }, [url]); | |||
| return { connect, content }; | |||
| }; | |||
| export const useSendMessageWithSse = ( | |||
| url: string = api.completeConversation, | |||
| ) => { | |||
| const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); | |||
| const [done, setDone] = useState(true); | |||
| export const useConnectWithSseNext = () => { | |||
| const [content, setContent] = useState<string>(''); | |||
| const sharedId = getSearchValue('shared_id'); | |||
| const authorization = sharedId | |||
| ? 'Bearer ' + sharedId | |||
| : authorizationUtil.getAuthorization(); | |||
| const send = useCallback( | |||
| async (body: any) => { | |||
| const response = await fetch(api.completeConversation, { | |||
| method: 'POST', | |||
| headers: { | |||
| [Authorization]: authorization, | |||
| 'Content-Type': 'application/json', | |||
| }, | |||
| body: JSON.stringify(body), | |||
| }); | |||
| const reader = response?.body | |||
| ?.pipeThrough(new TextDecoderStream()) | |||
| .getReader(); | |||
| // const reader = response.body.getReader(); | |||
| while (true) { | |||
| const { value, done } = await reader?.read(); | |||
| console.log('Received', value); | |||
| setContent(value); | |||
| if (done) break; | |||
| try { | |||
| setDone(false); | |||
| const response = await fetch(url, { | |||
| method: 'POST', | |||
| headers: { | |||
| [Authorization]: getAuthorization(), | |||
| 'Content-Type': 'application/json', | |||
| }, | |||
| body: JSON.stringify(body), | |||
| }); | |||
| const reader = response?.body | |||
| ?.pipeThrough(new TextDecoderStream()) | |||
| .pipeThrough(new EventSourceParserStream()) | |||
| .getReader(); | |||
| while (true) { | |||
| const x = await reader?.read(); | |||
| if (x) { | |||
| const { done, value } = x; | |||
| try { | |||
| const val = JSON.parse(value?.data || ''); | |||
| const d = val?.data; | |||
| if (typeof d !== 'boolean') { | |||
| console.info('data:', d); | |||
| setAnswer(d); | |||
| } | |||
| } catch (e) { | |||
| console.warn(e); | |||
| } | |||
| if (done) { | |||
| console.info('done'); | |||
| break; | |||
| } | |||
| } | |||
| } | |||
| console.info('done?'); | |||
| setDone(true); | |||
| return response; | |||
| } catch (e) { | |||
| setDone(true); | |||
| console.warn(e); | |||
| } | |||
| return response; | |||
| }, | |||
| [authorization], | |||
| [url], | |||
| ); | |||
| return { send, content }; | |||
| return { send, answer, done }; | |||
| }; | |||
| @@ -72,6 +72,11 @@ export interface IReference { | |||
| total: number; | |||
| } | |||
| export interface IAnswer { | |||
| answer: string; | |||
| reference: IReference; | |||
| } | |||
| export interface Docagg { | |||
| count: number; | |||
| doc_id: string; | |||
| @@ -25,6 +25,7 @@ export default { | |||
| comingSoon: 'Coming Soon', | |||
| download: 'Download', | |||
| close: 'Close', | |||
| preview: 'Preview', | |||
| }, | |||
| login: { | |||
| login: 'Sign in', | |||
| @@ -381,6 +382,7 @@ export default { | |||
| partialTitle: 'Partial Embed', | |||
| extensionTitle: 'Chrome Extension', | |||
| tokenError: 'Please create API Token first!', | |||
| searching: 'searching...', | |||
| }, | |||
| setting: { | |||
| profile: 'Profile', | |||
| @@ -25,6 +25,7 @@ export default { | |||
| comingSoon: '即將推出', | |||
| download: '下載', | |||
| close: '关闭', | |||
| preview: '預覽', | |||
| }, | |||
| login: { | |||
| login: '登入', | |||
| @@ -352,6 +353,7 @@ export default { | |||
| partialTitle: '部分嵌入', | |||
| extensionTitle: 'Chrome 插件', | |||
| tokenError: '請先創建 Api Token!', | |||
| searching: '搜索中', | |||
| }, | |||
| setting: { | |||
| profile: '概述', | |||
| @@ -25,6 +25,7 @@ export default { | |||
| comingSoon: '即将推出', | |||
| download: '下载', | |||
| close: '关闭', | |||
| preview: '预览', | |||
| }, | |||
| login: { | |||
| login: '登录', | |||
| @@ -369,6 +370,7 @@ export default { | |||
| partialTitle: '部分嵌入', | |||
| extensionTitle: 'Chrome 插件', | |||
| tokenError: '请先创建 Api Token!', | |||
| searching: '搜索中', | |||
| }, | |||
| setting: { | |||
| profile: '概要', | |||
| @@ -6,16 +6,7 @@ import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |||
| import { useSelectUserInfo } from '@/hooks/userSettingHook'; | |||
| import { IReference, Message } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import { | |||
| Avatar, | |||
| Button, | |||
| Drawer, | |||
| Flex, | |||
| Input, | |||
| List, | |||
| Skeleton, | |||
| Spin, | |||
| } from 'antd'; | |||
| import { Avatar, Button, Drawer, Flex, Input, List, Spin } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useMemo } from 'react'; | |||
| import { | |||
| @@ -32,20 +23,24 @@ import SvgIcon from '@/components/svg-icon'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { useGetDocumentUrl } from '@/hooks/documentHooks'; | |||
| import { getExtension, isPdf } from '@/utils/documentUtils'; | |||
| import { buildMessageItemReference } from '../utils'; | |||
| 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; | |||
| @@ -53,6 +48,14 @@ const MessageItem = ({ | |||
| 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, { | |||
| @@ -85,15 +88,11 @@ const MessageItem = ({ | |||
| <Flex vertical gap={8} flex={1}> | |||
| <b>{isAssistant ? '' : userInfo.nickname}</b> | |||
| <div className={styles.messageText}> | |||
| {item.content !== '' ? ( | |||
| <MarkdownContent | |||
| content={item.content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| ) : ( | |||
| <Skeleton active className={styles.messageEmpty} /> | |||
| )} | |||
| <MarkdownContent | |||
| content={content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| </div> | |||
| {isAssistant && referenceDocumentList.length > 0 && ( | |||
| <List | |||
| @@ -139,13 +138,19 @@ const ChatContainer = () => { | |||
| currentConversation: conversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| } = useFetchConversationOnMount(); | |||
| const { | |||
| handleInputChange, | |||
| handlePressEnter, | |||
| value, | |||
| loading: sendLoading, | |||
| } = useSendMessage(conversation, addNewestConversation, removeLatestMessage); | |||
| } = useSendMessage( | |||
| conversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| ); | |||
| const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = | |||
| useClickDrawer(); | |||
| const disabled = useGetSendButtonDisabled(); | |||
| @@ -159,19 +164,17 @@ const ChatContainer = () => { | |||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||
| <div> | |||
| <Spin spinning={loading}> | |||
| {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]; | |||
| {conversation?.message?.map((message, i) => { | |||
| return ( | |||
| <MessageItem | |||
| loading={ | |||
| message.role === MessageType.Assistant && | |||
| sendLoading && | |||
| conversation?.message.length - 1 === i | |||
| } | |||
| key={message.id} | |||
| item={message} | |||
| reference={reference} | |||
| reference={buildMessageItemReference(conversation, message)} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MessageItem> | |||
| ); | |||
| @@ -1,7 +1,6 @@ | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { fileIconMap } from '@/constants/common'; | |||
| import { | |||
| useCompleteConversation, | |||
| useCreateToken, | |||
| useFetchConversation, | |||
| useFetchConversationList, | |||
| @@ -24,8 +23,14 @@ import { | |||
| useShowDeleteConfirm, | |||
| useTranslate, | |||
| } from '@/hooks/commonHooks'; | |||
| import { useSendMessageWithSse } from '@/hooks/logicHooks'; | |||
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | |||
| import { IConversation, IDialog, IStats } from '@/interfaces/database/chat'; | |||
| import { | |||
| IAnswer, | |||
| IConversation, | |||
| IDialog, | |||
| IStats, | |||
| } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import { getFileExtension } from '@/utils'; | |||
| import { message } from 'antd'; | |||
| @@ -380,31 +385,56 @@ export const useSelectCurrentConversation = () => { | |||
| const dialog = useSelectCurrentDialog(); | |||
| const { conversationId, dialogId } = useGetChatSearchParams(); | |||
| const addNewestConversation = useCallback((message: string) => { | |||
| const addNewestConversation = useCallback( | |||
| (message: string, answer: string = '') => { | |||
| setCurrentConversation((pre) => { | |||
| return { | |||
| ...pre, | |||
| message: [ | |||
| ...pre.message, | |||
| { | |||
| role: MessageType.User, | |||
| content: message, | |||
| id: uuid(), | |||
| } as IMessage, | |||
| { | |||
| role: MessageType.Assistant, | |||
| content: answer, | |||
| id: uuid(), | |||
| reference: [], | |||
| } as IMessage, | |||
| ], | |||
| }; | |||
| }); | |||
| }, | |||
| [], | |||
| ); | |||
| const addNewestAnswer = useCallback((answer: IAnswer) => { | |||
| setCurrentConversation((pre) => { | |||
| return { | |||
| ...pre, | |||
| message: [ | |||
| ...pre.message, | |||
| { | |||
| role: MessageType.User, | |||
| content: message, | |||
| id: uuid(), | |||
| } as IMessage, | |||
| { | |||
| role: MessageType.Assistant, | |||
| content: '', | |||
| id: uuid(), | |||
| reference: [], | |||
| } as IMessage, | |||
| ], | |||
| }; | |||
| const latestMessage = pre.message?.at(-1); | |||
| if (latestMessage) { | |||
| return { | |||
| ...pre, | |||
| message: [ | |||
| ...pre.message.slice(0, -1), | |||
| { | |||
| ...latestMessage, | |||
| content: answer.answer, | |||
| reference: answer.reference, | |||
| } as IMessage, | |||
| ], | |||
| }; | |||
| } | |||
| return pre; | |||
| }); | |||
| }, []); | |||
| const removeLatestMessage = useCallback(() => { | |||
| console.info('removeLatestMessage'); | |||
| setCurrentConversation((pre) => { | |||
| const nextMessages = pre.message.slice(0, -2); | |||
| const nextMessages = pre.message?.slice(0, -2) ?? []; | |||
| return { | |||
| ...pre, | |||
| message: nextMessages, | |||
| @@ -441,7 +471,12 @@ export const useSelectCurrentConversation = () => { | |||
| } | |||
| }, [conversation, conversationId]); | |||
| return { currentConversation, addNewestConversation, removeLatestMessage }; | |||
| return { | |||
| currentConversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| }; | |||
| }; | |||
| export const useScrollToBottom = (currentConversation: IClientConversation) => { | |||
| @@ -464,8 +499,12 @@ export const useScrollToBottom = (currentConversation: IClientConversation) => { | |||
| export const useFetchConversationOnMount = () => { | |||
| const { conversationId } = useGetChatSearchParams(); | |||
| const fetchConversation = useFetchConversation(); | |||
| const { currentConversation, addNewestConversation, removeLatestMessage } = | |||
| useSelectCurrentConversation(); | |||
| const { | |||
| currentConversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| } = useSelectCurrentConversation(); | |||
| const ref = useScrollToBottom(currentConversation); | |||
| const fetchConversationOnMount = useCallback(() => { | |||
| @@ -483,6 +522,7 @@ export const useFetchConversationOnMount = () => { | |||
| addNewestConversation, | |||
| ref, | |||
| removeLatestMessage, | |||
| addNewestAnswer, | |||
| }; | |||
| }; | |||
| @@ -504,25 +544,22 @@ export const useHandleMessageInputChange = () => { | |||
| export const useSendMessage = ( | |||
| conversation: IClientConversation, | |||
| addNewestConversation: (message: string) => void, | |||
| addNewestConversation: (message: string, answer?: string) => void, | |||
| removeLatestMessage: () => void, | |||
| addNewestAnswer: (answer: IAnswer) => void, | |||
| ) => { | |||
| const loading = useOneNamespaceEffectsLoading('chatModel', [ | |||
| 'completeConversation', | |||
| ]); | |||
| const { setConversation } = useSetConversation(); | |||
| const { conversationId } = useGetChatSearchParams(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const fetchConversation = useFetchConversation(); | |||
| const completeConversation = useCompleteConversation(); | |||
| const { handleClickConversation } = useClickConversationCard(); | |||
| // const { send } = useConnectWithSseNext(); | |||
| const { send, answer, done } = useSendMessageWithSse(); | |||
| const sendMessage = useCallback( | |||
| async (message: string, id?: string) => { | |||
| const retcode = await completeConversation({ | |||
| const res: Response = await send({ | |||
| conversation_id: id ?? conversationId, | |||
| messages: [ | |||
| ...(conversation?.message ?? []).map((x: IMessage) => omit(x, 'id')), | |||
| @@ -533,27 +570,33 @@ export const useSendMessage = ( | |||
| ], | |||
| }); | |||
| if (retcode === 0) { | |||
| if (res.status === 200) { | |||
| if (id) { | |||
| console.info('111'); | |||
| // new conversation | |||
| handleClickConversation(id); | |||
| } else { | |||
| fetchConversation(conversationId); | |||
| console.info('222'); | |||
| // fetchConversation(conversationId); | |||
| } | |||
| } else { | |||
| console.info('333'); | |||
| // cancel loading | |||
| setValue(message); | |||
| console.info('removeLatestMessage111'); | |||
| removeLatestMessage(); | |||
| } | |||
| console.info('false'); | |||
| }, | |||
| [ | |||
| conversation?.message, | |||
| conversationId, | |||
| fetchConversation, | |||
| // fetchConversation, | |||
| handleClickConversation, | |||
| removeLatestMessage, | |||
| setValue, | |||
| completeConversation, | |||
| send, | |||
| ], | |||
| ); | |||
| @@ -572,19 +615,27 @@ export const useSendMessage = ( | |||
| [conversationId, setConversation, sendMessage], | |||
| ); | |||
| const handlePressEnter = () => { | |||
| if (!loading) { | |||
| useEffect(() => { | |||
| if (answer.answer) { | |||
| addNewestAnswer(answer); | |||
| console.info('true?'); | |||
| console.info('send msg:', answer.answer); | |||
| } | |||
| }, [answer, addNewestAnswer]); | |||
| const handlePressEnter = useCallback(() => { | |||
| if (done) { | |||
| setValue(''); | |||
| addNewestConversation(value); | |||
| handleSendMessage(value.trim()); | |||
| } | |||
| }; | |||
| addNewestConversation(value); | |||
| }, [addNewestConversation, handleSendMessage, done, setValue, value]); | |||
| return { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading, | |||
| loading: !done, | |||
| }; | |||
| }; | |||
| @@ -1,4 +1,4 @@ | |||
| import { IConversation, Message } from '@/interfaces/database/chat'; | |||
| import { IConversation, IReference, Message } from '@/interfaces/database/chat'; | |||
| import { FormInstance } from 'antd'; | |||
| export interface ISegmentedContentProps { | |||
| @@ -24,6 +24,7 @@ export type IPromptConfigParameters = Omit<VariableTableDataType, 'variable'>; | |||
| export interface IMessage extends Message { | |||
| id: string; | |||
| reference?: IReference; // the latest news has reference | |||
| } | |||
| export interface IClientConversation extends IConversation { | |||
| @@ -23,3 +23,23 @@ | |||
| .referenceIcon { | |||
| padding: 0 6px; | |||
| } | |||
| .cursor { | |||
| display: inline-block; | |||
| width: 1px; | |||
| height: 16px; | |||
| background-color: black; | |||
| animation: blink 0.6s infinite; | |||
| vertical-align: text-top; | |||
| @keyframes blink { | |||
| 0% { | |||
| opacity: 1; | |||
| } | |||
| 50% { | |||
| opacity: 0; | |||
| } | |||
| 100% { | |||
| opacity: 1; | |||
| } | |||
| } | |||
| } | |||
| @@ -16,6 +16,7 @@ import { visitParents } from 'unist-util-visit-parents'; | |||
| import styles from './index.less'; | |||
| const reg = /(#{2}\d+\${2})/g; | |||
| const curReg = /(~{2}\d+\${2})/g; | |||
| const getChunkIndex = (match: string) => Number(match.slice(2, -2)); | |||
| // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem. | |||
| @@ -61,7 +62,7 @@ const MarkdownContent = ({ | |||
| (chunkIndex: number) => { | |||
| const chunks = reference?.chunks ?? []; | |||
| const chunkItem = chunks[chunkIndex]; | |||
| const document = reference?.doc_aggs.find( | |||
| const document = reference?.doc_aggs?.find( | |||
| (x) => x?.doc_id === chunkItem?.doc_id, | |||
| ); | |||
| const documentId = document?.doc_id; | |||
| @@ -129,7 +130,7 @@ const MarkdownContent = ({ | |||
| const renderReference = useCallback( | |||
| (text: string) => { | |||
| return reactStringReplace(text, reg, (match, i) => { | |||
| let replacedText = reactStringReplace(text, reg, (match, i) => { | |||
| const chunkIndex = getChunkIndex(match); | |||
| return ( | |||
| <Popover content={getPopoverContent(chunkIndex)}> | |||
| @@ -137,6 +138,12 @@ const MarkdownContent = ({ | |||
| </Popover> | |||
| ); | |||
| }); | |||
| replacedText = reactStringReplace(replacedText, curReg, (match, i) => ( | |||
| <span className={styles.cursor} key={i}></span> | |||
| )); | |||
| return replacedText; | |||
| }, | |||
| [getPopoverContent], | |||
| ); | |||
| @@ -1,51 +1,11 @@ | |||
| import { useEffect } from 'react'; | |||
| import { | |||
| useCreateSharedConversationOnMount, | |||
| useSelectCurrentSharedConversation, | |||
| useSendSharedMessage, | |||
| } from '../shared-hooks'; | |||
| import ChatContainer from './large'; | |||
| import styles from './index.less'; | |||
| const SharedChat = () => { | |||
| const { conversationId } = useCreateSharedConversationOnMount(); | |||
| const { | |||
| currentConversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| ref, | |||
| loading, | |||
| setCurrentConversation, | |||
| } = useSelectCurrentSharedConversation(conversationId); | |||
| const { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading: sendLoading, | |||
| } = useSendSharedMessage( | |||
| currentConversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| setCurrentConversation, | |||
| ); | |||
| useEffect(() => { | |||
| console.info(location.href); | |||
| }, []); | |||
| return ( | |||
| <div className={styles.chatWrapper}> | |||
| <ChatContainer | |||
| value={value} | |||
| handleInputChange={handleInputChange} | |||
| handlePressEnter={handlePressEnter} | |||
| loading={loading} | |||
| sendLoading={sendLoading} | |||
| conversation={currentConversation} | |||
| ref={ref} | |||
| ></ChatContainer> | |||
| <ChatContainer></ChatContainer> | |||
| </div> | |||
| ); | |||
| }; | |||
| @@ -1,18 +1,50 @@ | |||
| import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { Message } from '@/interfaces/database/chat'; | |||
| import { Avatar, Button, Flex, Input, Skeleton, Spin } from 'antd'; | |||
| import { IReference, Message } from '@/interfaces/database/chat'; | |||
| import { Avatar, Button, Flex, Input, List, Spin } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useSelectConversationLoading } from '../hooks'; | |||
| import HightLightMarkdown from '@/components/highlight-markdown'; | |||
| import React, { ChangeEventHandler, forwardRef } from 'react'; | |||
| import { IClientConversation } from '../interface'; | |||
| import NewDocumentLink from '@/components/new-document-link'; | |||
| import SvgIcon from '@/components/svg-icon'; | |||
| import { useGetDocumentUrl } from '@/hooks/documentHooks'; | |||
| import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |||
| import { getExtension, isPdf } from '@/utils/documentUtils'; | |||
| import { forwardRef, useMemo } from 'react'; | |||
| import MarkdownContent from '../markdown-content'; | |||
| import { | |||
| useCreateSharedConversationOnMount, | |||
| useSelectCurrentSharedConversation, | |||
| useSendSharedMessage, | |||
| } from '../shared-hooks'; | |||
| import { buildMessageItemReference } from '../utils'; | |||
| import styles from './index.less'; | |||
| const MessageItem = ({ item }: { item: Message }) => { | |||
| const MessageItem = ({ | |||
| item, | |||
| reference, | |||
| loading = false, | |||
| }: { | |||
| item: Message; | |||
| reference: IReference; | |||
| loading?: boolean; | |||
| }) => { | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| const { t } = useTranslate('chat'); | |||
| const fileThumbnails = useSelectFileThumbnails(); | |||
| const getDocumentUrl = useGetDocumentUrl(); | |||
| 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 | |||
| @@ -45,12 +77,43 @@ const MessageItem = ({ item }: { item: Message }) => { | |||
| <Flex vertical gap={8} flex={1}> | |||
| <b>{isAssistant ? '' : 'You'}</b> | |||
| <div className={styles.messageText}> | |||
| {item.content !== '' ? ( | |||
| <HightLightMarkdown>{item.content}</HightLightMarkdown> | |||
| ) : ( | |||
| <Skeleton active className={styles.messageEmpty} /> | |||
| )} | |||
| <MarkdownContent | |||
| reference={reference} | |||
| clickDocumentButton={() => {}} | |||
| content={content} | |||
| ></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}></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> | |||
| @@ -58,28 +121,31 @@ const MessageItem = ({ item }: { item: Message }) => { | |||
| ); | |||
| }; | |||
| interface IProps { | |||
| handlePressEnter(): void; | |||
| handleInputChange: ChangeEventHandler<HTMLInputElement>; | |||
| value: string; | |||
| loading: boolean; | |||
| sendLoading: boolean; | |||
| conversation: IClientConversation; | |||
| ref: React.LegacyRef<any>; | |||
| } | |||
| const ChatContainer = () => { | |||
| const { t } = useTranslate('chat'); | |||
| const { conversationId } = useCreateSharedConversationOnMount(); | |||
| const { | |||
| currentConversation: conversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| ref, | |||
| loading, | |||
| setCurrentConversation, | |||
| addNewestAnswer, | |||
| } = useSelectCurrentSharedConversation(conversationId); | |||
| const ChatContainer = ( | |||
| { | |||
| const { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading: sendLoading, | |||
| } = useSendSharedMessage( | |||
| conversation, | |||
| }: IProps, | |||
| ref: React.LegacyRef<any>, | |||
| ) => { | |||
| const loading = useSelectConversationLoading(); | |||
| const { t } = useTranslate('chat'); | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| setCurrentConversation, | |||
| addNewestAnswer, | |||
| ); | |||
| return ( | |||
| <> | |||
| @@ -87,9 +153,18 @@ const ChatContainer = ( | |||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||
| <div> | |||
| <Spin spinning={loading}> | |||
| {conversation?.message?.map((message) => { | |||
| {conversation?.message?.map((message, i) => { | |||
| return ( | |||
| <MessageItem key={message.id} item={message}></MessageItem> | |||
| <MessageItem | |||
| key={message.id} | |||
| item={message} | |||
| reference={buildMessageItemReference(conversation, message)} | |||
| loading={ | |||
| message.role === MessageType.Assistant && | |||
| sendLoading && | |||
| conversation?.message.length - 1 === i | |||
| } | |||
| ></MessageItem> | |||
| ); | |||
| })} | |||
| </Spin> | |||
| @@ -1,10 +1,12 @@ | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { | |||
| useCompleteSharedConversation, | |||
| useCreateSharedConversation, | |||
| useFetchSharedConversation, | |||
| } from '@/hooks/chatHooks'; | |||
| import { useSendMessageWithSse } from '@/hooks/logicHooks'; | |||
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | |||
| import { IAnswer } from '@/interfaces/database/chat'; | |||
| import api from '@/utils/api'; | |||
| import omit from 'lodash/omit'; | |||
| import { | |||
| Dispatch, | |||
| @@ -76,6 +78,27 @@ export const useSelectCurrentSharedConversation = (conversationId: string) => { | |||
| }); | |||
| }, []); | |||
| const addNewestAnswer = useCallback((answer: IAnswer) => { | |||
| setCurrentConversation((pre) => { | |||
| const latestMessage = pre.message?.at(-1); | |||
| if (latestMessage) { | |||
| return { | |||
| ...pre, | |||
| message: [ | |||
| ...pre.message.slice(0, -1), | |||
| { | |||
| ...latestMessage, | |||
| content: answer.answer, | |||
| reference: answer.reference, | |||
| } as IMessage, | |||
| ], | |||
| }; | |||
| } | |||
| return pre; | |||
| }); | |||
| }, []); | |||
| const removeLatestMessage = useCallback(() => { | |||
| setCurrentConversation((pre) => { | |||
| const nextMessages = pre.message.slice(0, -2); | |||
| @@ -106,6 +129,7 @@ export const useSelectCurrentSharedConversation = (conversationId: string) => { | |||
| loading, | |||
| ref, | |||
| setCurrentConversation, | |||
| addNewestAnswer, | |||
| }; | |||
| }; | |||
| @@ -114,20 +138,19 @@ export const useSendSharedMessage = ( | |||
| addNewestConversation: (message: string) => void, | |||
| removeLatestMessage: () => void, | |||
| setCurrentConversation: Dispatch<SetStateAction<IClientConversation>>, | |||
| addNewestAnswer: (answer: IAnswer) => void, | |||
| ) => { | |||
| const conversationId = conversation.id; | |||
| const loading = useOneNamespaceEffectsLoading('chatModel', [ | |||
| 'completeExternalConversation', | |||
| ]); | |||
| const setConversation = useCreateSharedConversation(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const fetchConversation = useFetchSharedConversation(); | |||
| const completeConversation = useCompleteSharedConversation(); | |||
| const { send, answer, done } = useSendMessageWithSse( | |||
| api.completeExternalConversation, | |||
| ); | |||
| const sendMessage = useCallback( | |||
| async (message: string, id?: string) => { | |||
| const retcode = await completeConversation({ | |||
| const res: Response = await send({ | |||
| conversation_id: id ?? conversationId, | |||
| quote: false, | |||
| messages: [ | |||
| @@ -139,11 +162,11 @@ export const useSendSharedMessage = ( | |||
| ], | |||
| }); | |||
| if (retcode === 0) { | |||
| const data = await fetchConversation(conversationId); | |||
| if (data.retcode === 0) { | |||
| setCurrentConversation(data.data); | |||
| } | |||
| if (res?.status === 200) { | |||
| // const data = await fetchConversation(conversationId); | |||
| // if (data.retcode === 0) { | |||
| // setCurrentConversation(data.data); | |||
| // } | |||
| } else { | |||
| // cancel loading | |||
| setValue(message); | |||
| @@ -153,11 +176,11 @@ export const useSendSharedMessage = ( | |||
| [ | |||
| conversationId, | |||
| conversation?.message, | |||
| fetchConversation, | |||
| // fetchConversation, | |||
| removeLatestMessage, | |||
| setValue, | |||
| completeConversation, | |||
| setCurrentConversation, | |||
| send, | |||
| // setCurrentConversation, | |||
| ], | |||
| ); | |||
| @@ -176,18 +199,24 @@ export const useSendSharedMessage = ( | |||
| [conversationId, setConversation, sendMessage], | |||
| ); | |||
| const handlePressEnter = () => { | |||
| if (!loading) { | |||
| useEffect(() => { | |||
| if (answer.answer) { | |||
| addNewestAnswer(answer); | |||
| } | |||
| }, [answer, addNewestAnswer]); | |||
| const handlePressEnter = useCallback(() => { | |||
| if (done) { | |||
| setValue(''); | |||
| addNewestConversation(value); | |||
| handleSendMessage(value.trim()); | |||
| } | |||
| }; | |||
| }, [addNewestConversation, done, handleSendMessage, setValue, value]); | |||
| return { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading, | |||
| loading: !done, | |||
| }; | |||
| }; | |||
| @@ -1,5 +1,7 @@ | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { IConversation, IReference } from '@/interfaces/database/chat'; | |||
| import { EmptyConversationId, variableEnabledFieldMap } from './constants'; | |||
| import { IClientConversation, IMessage } from './interface'; | |||
| export const excludeUnEnabledVariables = (values: any) => { | |||
| const unEnabledFields: Array<keyof typeof variableEnabledFieldMap> = | |||
| @@ -20,7 +22,7 @@ export const getDocumentIdsFromConversionReference = (data: IConversation) => { | |||
| const documentIds = data.reference.reduce( | |||
| (pre: Array<string>, cur: IReference) => { | |||
| cur.doc_aggs | |||
| .map((x) => x.doc_id) | |||
| ?.map((x) => x.doc_id) | |||
| .forEach((x) => { | |||
| if (pre.every((y) => y !== x)) { | |||
| pre.push(x); | |||
| @@ -32,3 +34,20 @@ export const getDocumentIdsFromConversionReference = (data: IConversation) => { | |||
| ); | |||
| return documentIds.join(','); | |||
| }; | |||
| export const buildMessageItemReference = ( | |||
| conversation: IClientConversation, | |||
| message: IMessage, | |||
| ) => { | |||
| const assistantMessages = conversation.message | |||
| ?.filter((x) => x.role === MessageType.Assistant) | |||
| .slice(1); | |||
| const referenceIndex = assistantMessages.findIndex( | |||
| (x) => x.id === message.id, | |||
| ); | |||
| const reference = message?.reference | |||
| ? message?.reference | |||
| : conversation.reference[referenceIndex]; | |||
| return reference; | |||
| }; | |||
| @@ -1,5 +1,5 @@ | |||
| import { Authorization, Token, UserInfo } from '@/constants/authorization'; | |||
| import { getSearchValue } from './commonUtil'; | |||
| const KeySet = [Authorization, Token, UserInfo]; | |||
| const storage = { | |||
| @@ -21,7 +21,7 @@ const storage = { | |||
| setToken: (value: string) => { | |||
| localStorage.setItem(Token, value); | |||
| }, | |||
| setUserInfo: (value: string | Object) => { | |||
| setUserInfo: (value: string | Record<string, unknown>) => { | |||
| let valueStr = typeof value !== 'string' ? JSON.stringify(value) : value; | |||
| localStorage.setItem(UserInfo, valueStr); | |||
| }, | |||
| @@ -46,4 +46,13 @@ const storage = { | |||
| }, | |||
| }; | |||
| export const getAuthorization = () => { | |||
| const sharedId = getSearchValue('shared_id'); | |||
| const authorization = sharedId | |||
| ? 'Bearer ' + sharedId | |||
| : storage.getAuthorization() || ''; | |||
| return authorization; | |||
| }; | |||
| export default storage; | |||
| @@ -1,12 +1,12 @@ | |||
| import { Authorization } from '@/constants/authorization'; | |||
| import i18n from '@/locales/config'; | |||
| import authorizationUtil from '@/utils/authorizationUtil'; | |||
| import authorizationUtil, { getAuthorization } from '@/utils/authorizationUtil'; | |||
| import { message, notification } from 'antd'; | |||
| import { history } from 'umi'; | |||
| import { RequestMethod, extend } from 'umi-request'; | |||
| import { convertTheKeysOfTheObjectToSnake, getSearchValue } from './commonUtil'; | |||
| import { convertTheKeysOfTheObjectToSnake } from './commonUtil'; | |||
| const ABORT_REQUEST_ERR_MESSAGE = 'The user aborted a request.'; // 手动中断请求。errorHandler 抛出的error message | |||
| const ABORT_REQUEST_ERR_MESSAGE = 'The user aborted a request.'; | |||
| const RetcodeMessage = { | |||
| 200: i18n.t('message.200'), | |||
| @@ -41,9 +41,7 @@ type ResultCode = | |||
| | 502 | |||
| | 503 | |||
| | 504; | |||
| /** | |||
| * 异常处理程序 | |||
| */ | |||
| interface ResponseType { | |||
| retcode: number; | |||
| data: any; | |||
| @@ -55,7 +53,6 @@ const errorHandler = (error: { | |||
| message: string; | |||
| }): Response => { | |||
| const { response } = error; | |||
| // 手动中断请求 abort | |||
| if (error.message === ABORT_REQUEST_ERR_MESSAGE) { | |||
| console.log('user abort request'); | |||
| } else { | |||
| @@ -77,20 +74,13 @@ const errorHandler = (error: { | |||
| return response; | |||
| }; | |||
| /** | |||
| * 配置request请求时的默认参数 | |||
| */ | |||
| const request: RequestMethod = extend({ | |||
| errorHandler, // 默认错误处理 | |||
| errorHandler, | |||
| timeout: 300000, | |||
| getResponse: true, | |||
| }); | |||
| request.interceptors.request.use((url: string, options: any) => { | |||
| const sharedId = getSearchValue('shared_id'); | |||
| const authorization = sharedId | |||
| ? 'Bearer ' + sharedId | |||
| : authorizationUtil.getAuthorization(); | |||
| const data = convertTheKeysOfTheObjectToSnake(options.data); | |||
| const params = convertTheKeysOfTheObjectToSnake(options.params); | |||
| @@ -101,7 +91,9 @@ request.interceptors.request.use((url: string, options: any) => { | |||
| data, | |||
| params, | |||
| headers: { | |||
| ...(options.skipToken ? undefined : { [Authorization]: authorization }), | |||
| ...(options.skipToken | |||
| ? undefined | |||
| : { [Authorization]: getAuthorization() }), | |||
| ...options.headers, | |||
| }, | |||
| interceptors: true, | |||
| @@ -109,16 +101,11 @@ request.interceptors.request.use((url: string, options: any) => { | |||
| }; | |||
| }); | |||
| /* | |||
| * 请求response拦截器 | |||
| * */ | |||
| request.interceptors.response.use(async (response: any, options) => { | |||
| if (options.responseType === 'blob') { | |||
| return response; | |||
| } | |||
| const data: ResponseType = await response.clone().json(); | |||
| // response 拦截 | |||
| if (data.retcode === 401 || data.retcode === 401) { | |||
| notification.error({ | |||