feat: Support for conversational streaming (#809)

### What problem does this PR solve?

feat: Support for conversational streaming
#709

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu 2024-05-16 20:15:02 +08:00 committed by GitHub
parent 95f809187e
commit c6c9dbde64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 424 additions and 255 deletions

View File

@ -0,0 +1 @@
PORT=9222

9
web/package-lock.json generated
View File

@ -15,6 +15,7 @@
"axios": "^1.6.3", "axios": "^1.6.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"eventsource-parser": "^1.1.2",
"i18next": "^23.7.16", "i18next": "^23.7.16",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",
@ -10206,6 +10207,14 @@
"node": ">=0.8.x" "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": { "node_modules/evp_bytestokey": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmmirror.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",

View File

@ -3,7 +3,7 @@
"author": "zhaofengchao <13723060510@163.com>", "author": "zhaofengchao <13723060510@163.com>",
"scripts": { "scripts": {
"build": "umi build", "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", "postinstall": "umi setup",
"lint": "umi lint --eslint-only", "lint": "umi lint --eslint-only",
"setup": "umi setup", "setup": "umi setup",
@ -19,6 +19,7 @@
"axios": "^1.6.3", "axios": "^1.6.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"eventsource-parser": "^1.1.2",
"i18next": "^23.7.16", "i18next": "^23.7.16",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"jsencrypt": "^3.3.2", "jsencrypt": "^3.3.2",

View File

@ -18,7 +18,7 @@ const NewDocumentLink = ({
onClick={!preventDefault ? undefined : (e) => e.preventDefault()} onClick={!preventDefault ? undefined : (e) => e.preventDefault()}
href={link} href={link}
rel="noreferrer" rel="noreferrer"
style={{ color }} style={{ color, wordBreak: 'break-all' }}
> >
{children} {children}
</a> </a>

View File

@ -154,6 +154,9 @@ export const useRemoveConversation = () => {
return removeConversation; return removeConversation;
}; };
/*
@deprecated
*/
export const useCompleteConversation = () => { export const useCompleteConversation = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -283,20 +286,4 @@ export const useFetchSharedConversation = () => {
return fetchSharedConversation; 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 //#endregion

View File

@ -1,13 +1,14 @@
import { Authorization } from '@/constants/authorization'; import { Authorization } from '@/constants/authorization';
import { LanguageTranslationMap } from '@/constants/common'; import { LanguageTranslationMap } from '@/constants/common';
import { Pagination } from '@/interfaces/common'; import { Pagination } from '@/interfaces/common';
import { IAnswer } from '@/interfaces/database/chat';
import { IKnowledgeFile } from '@/interfaces/database/knowledge'; import { IKnowledgeFile } from '@/interfaces/database/knowledge';
import { IChangeParserConfigRequestBody } from '@/interfaces/request/document'; import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
import api from '@/utils/api'; import api from '@/utils/api';
import authorizationUtil from '@/utils/authorizationUtil'; import { getAuthorization } from '@/utils/authorizationUtil';
import { getSearchValue } from '@/utils/commonUtil';
import { PaginationProps } from 'antd'; import { PaginationProps } from 'antd';
import axios from 'axios'; import axios from 'axios';
import { EventSourceParserStream } from 'eventsource-parser/stream';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch } from 'umi'; import { useDispatch } from 'umi';
@ -138,62 +139,60 @@ export const useFetchAppConf = () => {
return appConf; return appConf;
}; };
export const useConnectWithSse = (url: string) => { export const useSendMessageWithSse = (
const [content, setContent] = useState<string>(''); url: string = api.completeConversation,
) => {
const [answer, setAnswer] = useState<IAnswer>({} as IAnswer);
const [done, setDone] = useState(true);
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 useConnectWithSseNext = () => {
const [content, setContent] = useState<string>('');
const sharedId = getSearchValue('shared_id');
const authorization = sharedId
? 'Bearer ' + sharedId
: authorizationUtil.getAuthorization();
const send = useCallback( const send = useCallback(
async (body: any) => { async (body: any) => {
const response = await fetch(api.completeConversation, { try {
setDone(false);
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
[Authorization]: authorization, [Authorization]: getAuthorization(),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const reader = response?.body const reader = response?.body
?.pipeThrough(new TextDecoderStream()) ?.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
.getReader(); .getReader();
// const reader = response.body.getReader();
while (true) { while (true) {
const { value, done } = await reader?.read(); const x = await reader?.read();
console.log('Received', value); if (x) {
setContent(value); const { done, value } = x;
if (done) break; 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; return response;
} catch (e) {
setDone(true);
console.warn(e);
}
}, },
[authorization], [url],
); );
return { send, content }; return { send, answer, done };
}; };

View File

@ -72,6 +72,11 @@ export interface IReference {
total: number; total: number;
} }
export interface IAnswer {
answer: string;
reference: IReference;
}
export interface Docagg { export interface Docagg {
count: number; count: number;
doc_id: string; doc_id: string;

View File

@ -25,6 +25,7 @@ export default {
comingSoon: 'Coming Soon', comingSoon: 'Coming Soon',
download: 'Download', download: 'Download',
close: 'Close', close: 'Close',
preview: 'Preview',
}, },
login: { login: {
login: 'Sign in', login: 'Sign in',
@ -381,6 +382,7 @@ export default {
partialTitle: 'Partial Embed', partialTitle: 'Partial Embed',
extensionTitle: 'Chrome Extension', extensionTitle: 'Chrome Extension',
tokenError: 'Please create API Token first!', tokenError: 'Please create API Token first!',
searching: 'searching...',
}, },
setting: { setting: {
profile: 'Profile', profile: 'Profile',

View File

@ -25,6 +25,7 @@ export default {
comingSoon: '即將推出', comingSoon: '即將推出',
download: '下載', download: '下載',
close: '关闭', close: '关闭',
preview: '預覽',
}, },
login: { login: {
login: '登入', login: '登入',
@ -352,6 +353,7 @@ export default {
partialTitle: '部分嵌入', partialTitle: '部分嵌入',
extensionTitle: 'Chrome 插件', extensionTitle: 'Chrome 插件',
tokenError: '請先創建 Api Token!', tokenError: '請先創建 Api Token!',
searching: '搜索中',
}, },
setting: { setting: {
profile: '概述', profile: '概述',

View File

@ -25,6 +25,7 @@ export default {
comingSoon: '即将推出', comingSoon: '即将推出',
download: '下载', download: '下载',
close: '关闭', close: '关闭',
preview: '预览',
}, },
login: { login: {
login: '登录', login: '登录',
@ -369,6 +370,7 @@ export default {
partialTitle: '部分嵌入', partialTitle: '部分嵌入',
extensionTitle: 'Chrome 插件', extensionTitle: 'Chrome 插件',
tokenError: '请先创建 Api Token!', tokenError: '请先创建 Api Token!',
searching: '搜索中',
}, },
setting: { setting: {
profile: '概要', profile: '概要',

View File

@ -6,16 +6,7 @@ import { useSelectFileThumbnails } from '@/hooks/knowledgeHook';
import { useSelectUserInfo } from '@/hooks/userSettingHook'; import { useSelectUserInfo } from '@/hooks/userSettingHook';
import { IReference, Message } from '@/interfaces/database/chat'; import { IReference, Message } from '@/interfaces/database/chat';
import { IChunk } from '@/interfaces/database/knowledge'; import { IChunk } from '@/interfaces/database/knowledge';
import { import { Avatar, Button, Drawer, Flex, Input, List, Spin } from 'antd';
Avatar,
Button,
Drawer,
Flex,
Input,
List,
Skeleton,
Spin,
} from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
@ -32,20 +23,24 @@ import SvgIcon from '@/components/svg-icon';
import { useTranslate } from '@/hooks/commonHooks'; import { useTranslate } from '@/hooks/commonHooks';
import { useGetDocumentUrl } from '@/hooks/documentHooks'; import { useGetDocumentUrl } from '@/hooks/documentHooks';
import { getExtension, isPdf } from '@/utils/documentUtils'; import { getExtension, isPdf } from '@/utils/documentUtils';
import { buildMessageItemReference } from '../utils';
import styles from './index.less'; import styles from './index.less';
const MessageItem = ({ const MessageItem = ({
item, item,
reference, reference,
loading = false,
clickDocumentButton, clickDocumentButton,
}: { }: {
item: Message; item: Message;
reference: IReference; reference: IReference;
loading?: boolean;
clickDocumentButton: (documentId: string, chunk: IChunk) => void; clickDocumentButton: (documentId: string, chunk: IChunk) => void;
}) => { }) => {
const userInfo = useSelectUserInfo(); const userInfo = useSelectUserInfo();
const fileThumbnails = useSelectFileThumbnails(); const fileThumbnails = useSelectFileThumbnails();
const getDocumentUrl = useGetDocumentUrl(); const getDocumentUrl = useGetDocumentUrl();
const { t } = useTranslate('chat');
const isAssistant = item.role === MessageType.Assistant; const isAssistant = item.role === MessageType.Assistant;
@ -53,6 +48,14 @@ const MessageItem = ({
return reference?.doc_aggs ?? []; return reference?.doc_aggs ?? [];
}, [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 ( return (
<div <div
className={classNames(styles.messageItem, { className={classNames(styles.messageItem, {
@ -85,15 +88,11 @@ const MessageItem = ({
<Flex vertical gap={8} flex={1}> <Flex vertical gap={8} flex={1}>
<b>{isAssistant ? '' : userInfo.nickname}</b> <b>{isAssistant ? '' : userInfo.nickname}</b>
<div className={styles.messageText}> <div className={styles.messageText}>
{item.content !== '' ? (
<MarkdownContent <MarkdownContent
content={item.content} content={content}
reference={reference} reference={reference}
clickDocumentButton={clickDocumentButton} clickDocumentButton={clickDocumentButton}
></MarkdownContent> ></MarkdownContent>
) : (
<Skeleton active className={styles.messageEmpty} />
)}
</div> </div>
{isAssistant && referenceDocumentList.length > 0 && ( {isAssistant && referenceDocumentList.length > 0 && (
<List <List
@ -139,13 +138,19 @@ const ChatContainer = () => {
currentConversation: conversation, currentConversation: conversation,
addNewestConversation, addNewestConversation,
removeLatestMessage, removeLatestMessage,
addNewestAnswer,
} = useFetchConversationOnMount(); } = useFetchConversationOnMount();
const { const {
handleInputChange, handleInputChange,
handlePressEnter, handlePressEnter,
value, value,
loading: sendLoading, loading: sendLoading,
} = useSendMessage(conversation, addNewestConversation, removeLatestMessage); } = useSendMessage(
conversation,
addNewestConversation,
removeLatestMessage,
addNewestAnswer,
);
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
useClickDrawer(); useClickDrawer();
const disabled = useGetSendButtonDisabled(); const disabled = useGetSendButtonDisabled();
@ -159,19 +164,17 @@ const ChatContainer = () => {
<Flex flex={1} vertical className={styles.messageContainer}> <Flex flex={1} vertical className={styles.messageContainer}>
<div> <div>
<Spin spinning={loading}> <Spin spinning={loading}>
{conversation?.message?.map((message) => { {conversation?.message?.map((message, i) => {
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];
return ( return (
<MessageItem <MessageItem
loading={
message.role === MessageType.Assistant &&
sendLoading &&
conversation?.message.length - 1 === i
}
key={message.id} key={message.id}
item={message} item={message}
reference={reference} reference={buildMessageItemReference(conversation, message)}
clickDocumentButton={clickDocumentButton} clickDocumentButton={clickDocumentButton}
></MessageItem> ></MessageItem>
); );

View File

@ -1,7 +1,6 @@
import { MessageType } from '@/constants/chat'; import { MessageType } from '@/constants/chat';
import { fileIconMap } from '@/constants/common'; import { fileIconMap } from '@/constants/common';
import { import {
useCompleteConversation,
useCreateToken, useCreateToken,
useFetchConversation, useFetchConversation,
useFetchConversationList, useFetchConversationList,
@ -24,8 +23,14 @@ import {
useShowDeleteConfirm, useShowDeleteConfirm,
useTranslate, useTranslate,
} from '@/hooks/commonHooks'; } from '@/hooks/commonHooks';
import { useSendMessageWithSse } from '@/hooks/logicHooks';
import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; 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 { IChunk } from '@/interfaces/database/knowledge';
import { getFileExtension } from '@/utils'; import { getFileExtension } from '@/utils';
import { message } from 'antd'; import { message } from 'antd';
@ -380,7 +385,8 @@ export const useSelectCurrentConversation = () => {
const dialog = useSelectCurrentDialog(); const dialog = useSelectCurrentDialog();
const { conversationId, dialogId } = useGetChatSearchParams(); const { conversationId, dialogId } = useGetChatSearchParams();
const addNewestConversation = useCallback((message: string) => { const addNewestConversation = useCallback(
(message: string, answer: string = '') => {
setCurrentConversation((pre) => { setCurrentConversation((pre) => {
return { return {
...pre, ...pre,
@ -393,18 +399,42 @@ export const useSelectCurrentConversation = () => {
} as IMessage, } as IMessage,
{ {
role: MessageType.Assistant, role: MessageType.Assistant,
content: '', content: answer,
id: uuid(), id: uuid(),
reference: [], reference: [],
} as IMessage, } as IMessage,
], ],
}; };
}); });
},
[],
);
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(() => { const removeLatestMessage = useCallback(() => {
console.info('removeLatestMessage');
setCurrentConversation((pre) => { setCurrentConversation((pre) => {
const nextMessages = pre.message.slice(0, -2); const nextMessages = pre.message?.slice(0, -2) ?? [];
return { return {
...pre, ...pre,
message: nextMessages, message: nextMessages,
@ -441,7 +471,12 @@ export const useSelectCurrentConversation = () => {
} }
}, [conversation, conversationId]); }, [conversation, conversationId]);
return { currentConversation, addNewestConversation, removeLatestMessage }; return {
currentConversation,
addNewestConversation,
removeLatestMessage,
addNewestAnswer,
};
}; };
export const useScrollToBottom = (currentConversation: IClientConversation) => { export const useScrollToBottom = (currentConversation: IClientConversation) => {
@ -464,8 +499,12 @@ export const useScrollToBottom = (currentConversation: IClientConversation) => {
export const useFetchConversationOnMount = () => { export const useFetchConversationOnMount = () => {
const { conversationId } = useGetChatSearchParams(); const { conversationId } = useGetChatSearchParams();
const fetchConversation = useFetchConversation(); const fetchConversation = useFetchConversation();
const { currentConversation, addNewestConversation, removeLatestMessage } = const {
useSelectCurrentConversation(); currentConversation,
addNewestConversation,
removeLatestMessage,
addNewestAnswer,
} = useSelectCurrentConversation();
const ref = useScrollToBottom(currentConversation); const ref = useScrollToBottom(currentConversation);
const fetchConversationOnMount = useCallback(() => { const fetchConversationOnMount = useCallback(() => {
@ -483,6 +522,7 @@ export const useFetchConversationOnMount = () => {
addNewestConversation, addNewestConversation,
ref, ref,
removeLatestMessage, removeLatestMessage,
addNewestAnswer,
}; };
}; };
@ -504,25 +544,22 @@ export const useHandleMessageInputChange = () => {
export const useSendMessage = ( export const useSendMessage = (
conversation: IClientConversation, conversation: IClientConversation,
addNewestConversation: (message: string) => void, addNewestConversation: (message: string, answer?: string) => void,
removeLatestMessage: () => void, removeLatestMessage: () => void,
addNewestAnswer: (answer: IAnswer) => void,
) => { ) => {
const loading = useOneNamespaceEffectsLoading('chatModel', [
'completeConversation',
]);
const { setConversation } = useSetConversation(); const { setConversation } = useSetConversation();
const { conversationId } = useGetChatSearchParams(); const { conversationId } = useGetChatSearchParams();
const { handleInputChange, value, setValue } = useHandleMessageInputChange(); const { handleInputChange, value, setValue } = useHandleMessageInputChange();
const fetchConversation = useFetchConversation(); const fetchConversation = useFetchConversation();
const completeConversation = useCompleteConversation();
const { handleClickConversation } = useClickConversationCard(); const { handleClickConversation } = useClickConversationCard();
// const { send } = useConnectWithSseNext(); const { send, answer, done } = useSendMessageWithSse();
const sendMessage = useCallback( const sendMessage = useCallback(
async (message: string, id?: string) => { async (message: string, id?: string) => {
const retcode = await completeConversation({ const res: Response = await send({
conversation_id: id ?? conversationId, conversation_id: id ?? conversationId,
messages: [ messages: [
...(conversation?.message ?? []).map((x: IMessage) => omit(x, 'id')), ...(conversation?.message ?? []).map((x: IMessage) => omit(x, 'id')),
@ -533,27 +570,33 @@ export const useSendMessage = (
], ],
}); });
if (retcode === 0) { if (res.status === 200) {
if (id) { if (id) {
console.info('111');
// new conversation // new conversation
handleClickConversation(id); handleClickConversation(id);
} else { } else {
fetchConversation(conversationId); console.info('222');
// fetchConversation(conversationId);
} }
} else { } else {
console.info('333');
// cancel loading // cancel loading
setValue(message); setValue(message);
console.info('removeLatestMessage111');
removeLatestMessage(); removeLatestMessage();
} }
console.info('false');
}, },
[ [
conversation?.message, conversation?.message,
conversationId, conversationId,
fetchConversation, // fetchConversation,
handleClickConversation, handleClickConversation,
removeLatestMessage, removeLatestMessage,
setValue, setValue,
completeConversation, send,
], ],
); );
@ -572,19 +615,27 @@ export const useSendMessage = (
[conversationId, setConversation, sendMessage], [conversationId, setConversation, sendMessage],
); );
const handlePressEnter = () => { useEffect(() => {
if (!loading) { if (answer.answer) {
addNewestAnswer(answer);
console.info('true?');
console.info('send msg:', answer.answer);
}
}, [answer, addNewestAnswer]);
const handlePressEnter = useCallback(() => {
if (done) {
setValue(''); setValue('');
addNewestConversation(value);
handleSendMessage(value.trim()); handleSendMessage(value.trim());
} }
}; addNewestConversation(value);
}, [addNewestConversation, handleSendMessage, done, setValue, value]);
return { return {
handlePressEnter, handlePressEnter,
handleInputChange, handleInputChange,
value, value,
loading, loading: !done,
}; };
}; };

View File

@ -1,4 +1,4 @@
import { IConversation, Message } from '@/interfaces/database/chat'; import { IConversation, IReference, Message } from '@/interfaces/database/chat';
import { FormInstance } from 'antd'; import { FormInstance } from 'antd';
export interface ISegmentedContentProps { export interface ISegmentedContentProps {
@ -24,6 +24,7 @@ export type IPromptConfigParameters = Omit<VariableTableDataType, 'variable'>;
export interface IMessage extends Message { export interface IMessage extends Message {
id: string; id: string;
reference?: IReference; // the latest news has reference
} }
export interface IClientConversation extends IConversation { export interface IClientConversation extends IConversation {

View File

@ -23,3 +23,23 @@
.referenceIcon { .referenceIcon {
padding: 0 6px; 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;
}
}
}

View File

@ -16,6 +16,7 @@ import { visitParents } from 'unist-util-visit-parents';
import styles from './index.less'; import styles from './index.less';
const reg = /(#{2}\d+\${2})/g; const reg = /(#{2}\d+\${2})/g;
const curReg = /(~{2}\d+\${2})/g;
const getChunkIndex = (match: string) => Number(match.slice(2, -2)); 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. // TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
@ -61,7 +62,7 @@ const MarkdownContent = ({
(chunkIndex: number) => { (chunkIndex: number) => {
const chunks = reference?.chunks ?? []; const chunks = reference?.chunks ?? [];
const chunkItem = chunks[chunkIndex]; const chunkItem = chunks[chunkIndex];
const document = reference?.doc_aggs.find( const document = reference?.doc_aggs?.find(
(x) => x?.doc_id === chunkItem?.doc_id, (x) => x?.doc_id === chunkItem?.doc_id,
); );
const documentId = document?.doc_id; const documentId = document?.doc_id;
@ -129,7 +130,7 @@ const MarkdownContent = ({
const renderReference = useCallback( const renderReference = useCallback(
(text: string) => { (text: string) => {
return reactStringReplace(text, reg, (match, i) => { let replacedText = reactStringReplace(text, reg, (match, i) => {
const chunkIndex = getChunkIndex(match); const chunkIndex = getChunkIndex(match);
return ( return (
<Popover content={getPopoverContent(chunkIndex)}> <Popover content={getPopoverContent(chunkIndex)}>
@ -137,6 +138,12 @@ const MarkdownContent = ({
</Popover> </Popover>
); );
}); });
replacedText = reactStringReplace(replacedText, curReg, (match, i) => (
<span className={styles.cursor} key={i}></span>
));
return replacedText;
}, },
[getPopoverContent], [getPopoverContent],
); );

View File

@ -1,51 +1,11 @@
import { useEffect } from 'react';
import {
useCreateSharedConversationOnMount,
useSelectCurrentSharedConversation,
useSendSharedMessage,
} from '../shared-hooks';
import ChatContainer from './large'; import ChatContainer from './large';
import styles from './index.less'; import styles from './index.less';
const SharedChat = () => { 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 ( return (
<div className={styles.chatWrapper}> <div className={styles.chatWrapper}>
<ChatContainer <ChatContainer></ChatContainer>
value={value}
handleInputChange={handleInputChange}
handlePressEnter={handlePressEnter}
loading={loading}
sendLoading={sendLoading}
conversation={currentConversation}
ref={ref}
></ChatContainer>
</div> </div>
); );
}; };

View File

@ -1,18 +1,50 @@
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat'; import { MessageType } from '@/constants/chat';
import { useTranslate } from '@/hooks/commonHooks'; import { useTranslate } from '@/hooks/commonHooks';
import { Message } from '@/interfaces/database/chat'; import { IReference, Message } from '@/interfaces/database/chat';
import { Avatar, Button, Flex, Input, Skeleton, Spin } from 'antd'; import { Avatar, Button, Flex, Input, List, Spin } from 'antd';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelectConversationLoading } from '../hooks';
import HightLightMarkdown from '@/components/highlight-markdown'; import NewDocumentLink from '@/components/new-document-link';
import React, { ChangeEventHandler, forwardRef } from 'react'; import SvgIcon from '@/components/svg-icon';
import { IClientConversation } from '../interface'; 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'; 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 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 ( return (
<div <div
@ -45,12 +77,43 @@ const MessageItem = ({ item }: { item: Message }) => {
<Flex vertical gap={8} flex={1}> <Flex vertical gap={8} flex={1}>
<b>{isAssistant ? '' : 'You'}</b> <b>{isAssistant ? '' : 'You'}</b>
<div className={styles.messageText}> <div className={styles.messageText}>
{item.content !== '' ? ( <MarkdownContent
<HightLightMarkdown>{item.content}</HightLightMarkdown> reference={reference}
) : ( clickDocumentButton={() => {}}
<Skeleton active className={styles.messageEmpty} /> content={content}
)} ></MarkdownContent>
</div> </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> </Flex>
</div> </div>
</section> </section>
@ -58,28 +121,31 @@ const MessageItem = ({ item }: { item: Message }) => {
); );
}; };
interface IProps { const ChatContainer = () => {
handlePressEnter(): void; const { t } = useTranslate('chat');
handleInputChange: ChangeEventHandler<HTMLInputElement>; const { conversationId } = useCreateSharedConversationOnMount();
value: string; const {
loading: boolean; currentConversation: conversation,
sendLoading: boolean; addNewestConversation,
conversation: IClientConversation; removeLatestMessage,
ref: React.LegacyRef<any>; ref,
} loading,
setCurrentConversation,
addNewestAnswer,
} = useSelectCurrentSharedConversation(conversationId);
const ChatContainer = ( const {
{
handlePressEnter, handlePressEnter,
handleInputChange, handleInputChange,
value, value,
loading: sendLoading, loading: sendLoading,
} = useSendSharedMessage(
conversation, conversation,
}: IProps, addNewestConversation,
ref: React.LegacyRef<any>, removeLatestMessage,
) => { setCurrentConversation,
const loading = useSelectConversationLoading(); addNewestAnswer,
const { t } = useTranslate('chat'); );
return ( return (
<> <>
@ -87,9 +153,18 @@ const ChatContainer = (
<Flex flex={1} vertical className={styles.messageContainer}> <Flex flex={1} vertical className={styles.messageContainer}>
<div> <div>
<Spin spinning={loading}> <Spin spinning={loading}>
{conversation?.message?.map((message) => { {conversation?.message?.map((message, i) => {
return ( 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> </Spin>

View File

@ -1,10 +1,12 @@
import { MessageType } from '@/constants/chat'; import { MessageType } from '@/constants/chat';
import { import {
useCompleteSharedConversation,
useCreateSharedConversation, useCreateSharedConversation,
useFetchSharedConversation, useFetchSharedConversation,
} from '@/hooks/chatHooks'; } from '@/hooks/chatHooks';
import { useSendMessageWithSse } from '@/hooks/logicHooks';
import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks';
import { IAnswer } from '@/interfaces/database/chat';
import api from '@/utils/api';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { import {
Dispatch, 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(() => { const removeLatestMessage = useCallback(() => {
setCurrentConversation((pre) => { setCurrentConversation((pre) => {
const nextMessages = pre.message.slice(0, -2); const nextMessages = pre.message.slice(0, -2);
@ -106,6 +129,7 @@ export const useSelectCurrentSharedConversation = (conversationId: string) => {
loading, loading,
ref, ref,
setCurrentConversation, setCurrentConversation,
addNewestAnswer,
}; };
}; };
@ -114,20 +138,19 @@ export const useSendSharedMessage = (
addNewestConversation: (message: string) => void, addNewestConversation: (message: string) => void,
removeLatestMessage: () => void, removeLatestMessage: () => void,
setCurrentConversation: Dispatch<SetStateAction<IClientConversation>>, setCurrentConversation: Dispatch<SetStateAction<IClientConversation>>,
addNewestAnswer: (answer: IAnswer) => void,
) => { ) => {
const conversationId = conversation.id; const conversationId = conversation.id;
const loading = useOneNamespaceEffectsLoading('chatModel', [
'completeExternalConversation',
]);
const setConversation = useCreateSharedConversation(); const setConversation = useCreateSharedConversation();
const { handleInputChange, value, setValue } = useHandleMessageInputChange(); const { handleInputChange, value, setValue } = useHandleMessageInputChange();
const fetchConversation = useFetchSharedConversation(); const { send, answer, done } = useSendMessageWithSse(
const completeConversation = useCompleteSharedConversation(); api.completeExternalConversation,
);
const sendMessage = useCallback( const sendMessage = useCallback(
async (message: string, id?: string) => { async (message: string, id?: string) => {
const retcode = await completeConversation({ const res: Response = await send({
conversation_id: id ?? conversationId, conversation_id: id ?? conversationId,
quote: false, quote: false,
messages: [ messages: [
@ -139,11 +162,11 @@ export const useSendSharedMessage = (
], ],
}); });
if (retcode === 0) { if (res?.status === 200) {
const data = await fetchConversation(conversationId); // const data = await fetchConversation(conversationId);
if (data.retcode === 0) { // if (data.retcode === 0) {
setCurrentConversation(data.data); // setCurrentConversation(data.data);
} // }
} else { } else {
// cancel loading // cancel loading
setValue(message); setValue(message);
@ -153,11 +176,11 @@ export const useSendSharedMessage = (
[ [
conversationId, conversationId,
conversation?.message, conversation?.message,
fetchConversation, // fetchConversation,
removeLatestMessage, removeLatestMessage,
setValue, setValue,
completeConversation, send,
setCurrentConversation, // setCurrentConversation,
], ],
); );
@ -176,18 +199,24 @@ export const useSendSharedMessage = (
[conversationId, setConversation, sendMessage], [conversationId, setConversation, sendMessage],
); );
const handlePressEnter = () => { useEffect(() => {
if (!loading) { if (answer.answer) {
addNewestAnswer(answer);
}
}, [answer, addNewestAnswer]);
const handlePressEnter = useCallback(() => {
if (done) {
setValue(''); setValue('');
addNewestConversation(value); addNewestConversation(value);
handleSendMessage(value.trim()); handleSendMessage(value.trim());
} }
}; }, [addNewestConversation, done, handleSendMessage, setValue, value]);
return { return {
handlePressEnter, handlePressEnter,
handleInputChange, handleInputChange,
value, value,
loading, loading: !done,
}; };
}; };

View File

@ -1,5 +1,7 @@
import { MessageType } from '@/constants/chat';
import { IConversation, IReference } from '@/interfaces/database/chat'; import { IConversation, IReference } from '@/interfaces/database/chat';
import { EmptyConversationId, variableEnabledFieldMap } from './constants'; import { EmptyConversationId, variableEnabledFieldMap } from './constants';
import { IClientConversation, IMessage } from './interface';
export const excludeUnEnabledVariables = (values: any) => { export const excludeUnEnabledVariables = (values: any) => {
const unEnabledFields: Array<keyof typeof variableEnabledFieldMap> = const unEnabledFields: Array<keyof typeof variableEnabledFieldMap> =
@ -20,7 +22,7 @@ export const getDocumentIdsFromConversionReference = (data: IConversation) => {
const documentIds = data.reference.reduce( const documentIds = data.reference.reduce(
(pre: Array<string>, cur: IReference) => { (pre: Array<string>, cur: IReference) => {
cur.doc_aggs cur.doc_aggs
.map((x) => x.doc_id) ?.map((x) => x.doc_id)
.forEach((x) => { .forEach((x) => {
if (pre.every((y) => y !== x)) { if (pre.every((y) => y !== x)) {
pre.push(x); pre.push(x);
@ -32,3 +34,20 @@ export const getDocumentIdsFromConversionReference = (data: IConversation) => {
); );
return documentIds.join(','); 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;
};

View File

@ -1,5 +1,5 @@
import { Authorization, Token, UserInfo } from '@/constants/authorization'; import { Authorization, Token, UserInfo } from '@/constants/authorization';
import { getSearchValue } from './commonUtil';
const KeySet = [Authorization, Token, UserInfo]; const KeySet = [Authorization, Token, UserInfo];
const storage = { const storage = {
@ -21,7 +21,7 @@ const storage = {
setToken: (value: string) => { setToken: (value: string) => {
localStorage.setItem(Token, value); localStorage.setItem(Token, value);
}, },
setUserInfo: (value: string | Object) => { setUserInfo: (value: string | Record<string, unknown>) => {
let valueStr = typeof value !== 'string' ? JSON.stringify(value) : value; let valueStr = typeof value !== 'string' ? JSON.stringify(value) : value;
localStorage.setItem(UserInfo, valueStr); 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; export default storage;

View File

@ -1,12 +1,12 @@
import { Authorization } from '@/constants/authorization'; import { Authorization } from '@/constants/authorization';
import i18n from '@/locales/config'; import i18n from '@/locales/config';
import authorizationUtil from '@/utils/authorizationUtil'; import authorizationUtil, { getAuthorization } from '@/utils/authorizationUtil';
import { message, notification } from 'antd'; import { message, notification } from 'antd';
import { history } from 'umi'; import { history } from 'umi';
import { RequestMethod, extend } from 'umi-request'; 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 = { const RetcodeMessage = {
200: i18n.t('message.200'), 200: i18n.t('message.200'),
@ -41,9 +41,7 @@ type ResultCode =
| 502 | 502
| 503 | 503
| 504; | 504;
/**
*
*/
interface ResponseType { interface ResponseType {
retcode: number; retcode: number;
data: any; data: any;
@ -55,7 +53,6 @@ const errorHandler = (error: {
message: string; message: string;
}): Response => { }): Response => {
const { response } = error; const { response } = error;
// 手动中断请求 abort
if (error.message === ABORT_REQUEST_ERR_MESSAGE) { if (error.message === ABORT_REQUEST_ERR_MESSAGE) {
console.log('user abort request'); console.log('user abort request');
} else { } else {
@ -77,20 +74,13 @@ const errorHandler = (error: {
return response; return response;
}; };
/**
* request请求时的默认参数
*/
const request: RequestMethod = extend({ const request: RequestMethod = extend({
errorHandler, // 默认错误处理 errorHandler,
timeout: 300000, timeout: 300000,
getResponse: true, getResponse: true,
}); });
request.interceptors.request.use((url: string, options: any) => { 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 data = convertTheKeysOfTheObjectToSnake(options.data);
const params = convertTheKeysOfTheObjectToSnake(options.params); const params = convertTheKeysOfTheObjectToSnake(options.params);
@ -101,7 +91,9 @@ request.interceptors.request.use((url: string, options: any) => {
data, data,
params, params,
headers: { headers: {
...(options.skipToken ? undefined : { [Authorization]: authorization }), ...(options.skipToken
? undefined
: { [Authorization]: getAuthorization() }),
...options.headers, ...options.headers,
}, },
interceptors: true, interceptors: true,
@ -109,16 +101,11 @@ request.interceptors.request.use((url: string, options: any) => {
}; };
}); });
/*
* response拦截器
* */
request.interceptors.response.use(async (response: any, options) => { request.interceptors.response.use(async (response: any, options) => {
if (options.responseType === 'blob') { if (options.responseType === 'blob') {
return response; return response;
} }
const data: ResponseType = await response.clone().json(); const data: ResponseType = await response.clone().json();
// response 拦截
if (data.retcode === 401 || data.retcode === 401) { if (data.retcode === 401 || data.retcode === 401) {
notification.error({ notification.error({