Browse Source

feat: render message reference and add avatar to MessageItem (#73)

* feat: add temporary conversation

* feat: add avatar to MessageItem

* feat: render message reference
tags/v0.1.0
balibabu 1 year ago
parent
commit
d1417102b6
No account linked to committer's email address

+ 710
- 9
web/package-lock.json
File diff suppressed because it is too large
View File


+ 3
- 0
web/package.json View File

@@ -25,8 +25,11 @@
"react-chat-elements": "^12.0.13",
"react-i18next": "^14.0.0",
"react-infinite-scroll-component": "^6.1.0",
"react-markdown": "^9.0.1",
"react-string-replace": "^1.1.1",
"umi": "^4.0.90",
"umi-request": "^1.4.0",
"unist-util-visit-parents": "^6.0.1",
"uuid": "^9.0.1"
},
"devDependencies": {

+ 25
- 0
web/src/assets/svg/assistant.svg
File diff suppressed because it is too large
View File


+ 22
- 0
web/src/hooks/userSettingHook.ts View File

@@ -0,0 +1,22 @@
import { IUserInfo } from '@/interfaces/database/userSetting';
import { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'umi';

export const useFetchUserInfo = () => {
const dispatch = useDispatch();
const fetchUserInfo = useCallback(() => {
dispatch({ type: 'settingModel/getUserInfo' });
}, [dispatch]);

useEffect(() => {
fetchUserInfo();
}, [fetchUserInfo]);
};

export const useSelectUserInfo = () => {
const userInfo: IUserInfo = useSelector(
(state: any) => state.settingModel.userInfo,
);

return userInfo;
};

+ 27
- 1
web/src/interfaces/database/chat.ts View File

@@ -54,7 +54,7 @@ export interface IConversation {
dialog_id: string;
id: string;
message: Message[];
reference: any[];
reference: IReference[];
name: string;
update_date: string;
update_time: number;
@@ -64,3 +64,29 @@ export interface Message {
content: string;
role: MessageType;
}

export interface IReference {
chunks: Chunk[];
doc_aggs: Docagg[];
total: number;
}

interface Docagg {
count: number;
doc_id: string;
doc_name: string;
}

interface Chunk {
chunk_id: string;
content_ltks: string;
content_with_weight: string;
doc_id: string;
docnm_kwd: string;
img_id: string;
important_kwd: any[];
kb_id: string;
similarity: number;
term_similarity: number;
vector_similarity: number;
}

+ 21
- 0
web/src/interfaces/database/userSetting.ts View File

@@ -0,0 +1,21 @@
export interface IUserInfo {
access_token: string;
avatar?: any;
color_schema: string;
create_date: string;
create_time: number;
email: string;
id: string;
is_active: string;
is_anonymous: string;
is_authenticated: string;
is_superuser: boolean;
language: string;
last_login_time: string;
login_channel: string;
nickname: string;
password: string;
status: string;
update_date: string;
update_time: number;
}

+ 10
- 7
web/src/layouts/components/header/index.tsx View File

@@ -19,17 +19,20 @@ const RagHeader = () => {
const navigate = useNavigate();
const { pathname } = useLocation();

const tagsData = [
{ path: '/knowledge', name: 'Knowledge Base', icon: KnowledgeBaseIcon },
{ path: '/chat', name: 'Chat', icon: StarIon },
{ path: '/file', name: 'File Management', icon: FileIcon },
];
const tagsData = useMemo(
() => [
{ path: '/knowledge', name: 'Knowledge Base', icon: KnowledgeBaseIcon },
{ path: '/chat', name: 'Chat', icon: StarIon },
{ path: '/file', name: 'File Management', icon: FileIcon },
],
[],
);

const currentPath = useMemo(() => {
return (
tagsData.find((x) => pathname.startsWith(x.path))?.name || 'knowledge'
);
}, [pathname]);
}, [pathname, tagsData]);

const handleChange = (path: string) => {
navigate(path);
@@ -48,7 +51,7 @@ const RagHeader = () => {
>
<Space size={12}>
<Logo className={styles.appIcon}></Logo>
<label className={styles.appName}>Infinity flow</label>
<label className={styles.appName}>RagFlow</label>
</Space>
<Space size={[0, 8]} wrap>
<Radio.Group

+ 9
- 2
web/src/layouts/components/user/index.tsx View File

@@ -1,3 +1,4 @@
import { useFetchUserInfo, useSelectUserInfo } from '@/hooks/userSettingHook';
import authorizationUtil from '@/utils/authorizationUtil';
import type { MenuProps } from 'antd';
import { Avatar, Button, Dropdown } from 'antd';
@@ -7,6 +8,7 @@ import { history } from 'umi';
const App: React.FC = () => {
const { t } = useTranslation();
const userInfo = useSelectUserInfo();
const logout = () => {
authorizationUtil.removeAll();
@@ -36,13 +38,18 @@ const App: React.FC = () => {
),
},
];
}, []);
}, [t]);
useFetchUserInfo();
return (
<Dropdown menu={{ items }} placement="bottomLeft" arrow>
<Avatar
size={32}
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
src={
userInfo.avatar ??
'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
}
/>
</Dropdown>
);

+ 25
- 2
web/src/pages/chat/chat-container/index.less View File

@@ -1,11 +1,34 @@
.chatContainer {
padding: 0 24px 24px;
.messageContainer {
overflow-y: auto;
}
}

.messageItem {
.messageItemContent {
padding: 24px 0;
.messageItemSection {
display: inline-block;
width: 300px;
}
.messageItemSectionLeft {
width: 70%;
}
.messageItemSectionRight {
width: 30%;
}
.messageItemContent {
display: inline-flex;
gap: 20px;
}
.messageItemContentReverse {
flex-direction: row-reverse;
}
.messageText {
padding: 0 14px;
background-color: rgba(249, 250, 251, 1);
}
.referenceIcon {
padding: 0 6px;
}
}


+ 110
- 19
web/src/pages/chat/chat-container/index.tsx View File

@@ -1,17 +1,59 @@
import { Button, Flex, Input, Typography } from 'antd';
import { ChangeEventHandler, useState } from 'react';

import { Message } from '@/interfaces/database/chat';
import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg';
import { MessageType } from '@/constants/chat';
import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks';
import { useSelectUserInfo } from '@/hooks/userSettingHook';
import { IReference, Message } from '@/interfaces/database/chat';
import { Avatar, Button, Flex, Input, Popover } from 'antd';
import classNames from 'classnames';
import { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import reactStringReplace from 'react-string-replace';
import { useFetchConversation, useSendMessage } from '../hooks';

import { MessageType } from '@/constants/chat';
import { IClientConversation } from '../interface';

import { InfoCircleOutlined } from '@ant-design/icons';
import Markdown from 'react-markdown';
import { visitParents } from 'unist-util-visit-parents';
import styles from './index.less';

const { Paragraph } = Typography;
const rehypeWrapReference = () => {
return function wrapTextTransform(tree: any) {
visitParents(tree, 'text', (node, ancestors) => {
if (ancestors.at(-1).tagName !== 'custom-typography') {
node.type = 'element';
node.tagName = 'custom-typography';
node.properties = {};
node.children = [{ type: 'text', value: node.value }];
}
});
};
};

const MessageItem = ({ item }: { item: Message; references: IReference[] }) => {
const userInfo = useSelectUserInfo();

const popoverContent = useMemo(
() => (
<div>
<p>Content</p>
<p>Content</p>
</div>
),
[],
);

const renderReference = useCallback(
(text: string) => {
return reactStringReplace(text, /#{2}\d{1,}\${2}/g, (match, i) => {
return (
<Popover content={popoverContent}>
<InfoCircleOutlined key={i} className={styles.referenceIcon} />
</Popover>
);
});
},
[popoverContent],
);

const MessageItem = ({ item }: { item: Message }) => {
return (
<div
className={classNames(styles.messageItem, {
@@ -19,11 +61,50 @@ const MessageItem = ({ item }: { item: Message }) => {
[styles.messageItemRight]: item.role === MessageType.User,
})}
>
<span className={styles.messageItemContent}>
<Paragraph ellipsis={{ tooltip: item.content, rows: 3 }}>
{item.content}
</Paragraph>
</span>
<section
className={classNames(styles.messageItemSection, {
[styles.messageItemSectionLeft]: item.role === MessageType.Assistant,
[styles.messageItemSectionRight]: item.role === MessageType.User,
})}
>
<div
className={classNames(styles.messageItemContent, {
[styles.messageItemContentReverse]: item.role === MessageType.User,
})}
>
{item.role === MessageType.User ? (
userInfo.avatar ?? (
<Avatar
size={40}
src={
userInfo.avatar ??
'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
}
/>
)
) : (
<AssistantIcon></AssistantIcon>
)}
<Flex vertical gap={8} flex={1}>
<b>
{item.role === MessageType.Assistant ? 'Resume Assistant' : 'You'}
</b>
<div className={styles.messageText}>
<Markdown
rehypePlugins={[rehypeWrapReference]}
components={
{
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
} as any
}
>
{item.content}
</Markdown>
</div>
</Flex>
</div>
</section>
</div>
);
};
@@ -32,9 +113,13 @@ const ChatContainer = () => {
const [value, setValue] = useState('');
const conversation: IClientConversation = useFetchConversation();
const { sendMessage } = useSendMessage();
const loading = useOneNamespaceEffectsLoading('chatModel', [
'completeConversation',
'getConversation',
]);

const handlePressEnter = () => {
console.info(value);
setValue('');
sendMessage(value);
};

@@ -44,17 +129,23 @@ const ChatContainer = () => {

return (
<Flex flex={1} className={styles.chatContainer} vertical>
<Flex flex={1} vertical>
{conversation?.message?.map((message) => (
<MessageItem key={message.id} item={message}></MessageItem>
))}
<Flex flex={1} vertical className={styles.messageContainer}>
<div>
{conversation?.message?.map((message) => (
<MessageItem
key={message.id}
item={message}
references={conversation.reference}
></MessageItem>
))}
</div>
</Flex>
<Input
size="large"
placeholder="Message Resume Assistant..."
value={value}
suffix={
<Button type="primary" onClick={handlePressEnter}>
<Button type="primary" onClick={handlePressEnter} loading={loading}>
Send
</Button>
}

+ 147
- 68
web/src/pages/chat/hooks.ts View File

@@ -1,8 +1,8 @@
import showDeleteConfirm from '@/components/deleting-confirm';
import { MessageType } from '@/constants/chat';
import { IDialog } from '@/interfaces/database/chat';
import { IConversation, IDialog } from '@/interfaces/database/chat';
import omit from 'lodash/omit';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSearchParams, useSelector } from 'umi';
import { v4 as uuid } from 'uuid';
import { ChatSearchParams, EmptyConversationId } from './constants';
@@ -11,6 +11,8 @@ import {
IMessage,
VariableTableDataType,
} from './interface';
import { ChatModelState } from './model';
import { isConversationIdNotExist } from './utils';

export const useFetchDialogList = () => {
const dispatch = useDispatch();
@@ -137,16 +139,46 @@ export const useRemoveDialog = () => {
return { onRemoveDialog };
};

export const useGetChatSearchParams = () => {
const [currentQueryParameters] = useSearchParams();

return {
dialogId: currentQueryParameters.get(ChatSearchParams.DialogId) || '',
conversationId:
currentQueryParameters.get(ChatSearchParams.ConversationId) || '',
};
};

export const useSetCurrentConversation = () => {
const dispatch = useDispatch();

const setCurrentConversation = useCallback(
(currentConversation: IClientConversation) => {
dispatch({
type: 'chatModel/setCurrentConversation',
payload: currentConversation,
});
},
[dispatch],
);

return setCurrentConversation;
};

export const useClickDialogCard = () => {
const [currentQueryParameters, setSearchParams] = useSearchParams();

const newQueryParameters: URLSearchParams = useMemo(() => {
return new URLSearchParams(currentQueryParameters.toString());
}, [currentQueryParameters]);
return new URLSearchParams();
}, []);

const handleClickDialog = useCallback(
(dialogId: string) => {
newQueryParameters.set(ChatSearchParams.DialogId, dialogId);
// newQueryParameters.set(
// ChatSearchParams.ConversationId,
// EmptyConversationId,
// );
setSearchParams(newQueryParameters);
},
[newQueryParameters, setSearchParams],
@@ -155,16 +187,6 @@ export const useClickDialogCard = () => {
return { handleClickDialog };
};

export const useGetChatSearchParams = () => {
const [currentQueryParameters] = useSearchParams();

return {
dialogId: currentQueryParameters.get(ChatSearchParams.DialogId) || '',
conversationId:
currentQueryParameters.get(ChatSearchParams.ConversationId) || '',
};
};

export const useSelectFirstDialogOnMount = () => {
const dialogList = useFetchDialogList();
const { dialogId } = useGetChatSearchParams();
@@ -182,70 +204,42 @@ export const useSelectFirstDialogOnMount = () => {

//#region conversation

export const useFetchConversationList = (dialogId?: string) => {
const dispatch = useDispatch();
const conversationList: any[] = useSelector(
(state: any) => state.chatModel.conversationList,
);

const fetchConversationList = useCallback(() => {
if (dialogId) {
dispatch({
type: 'chatModel/listConversation',
payload: { dialog_id: dialogId },
});
}
}, [dispatch, dialogId]);

useEffect(() => {
fetchConversationList();
}, [fetchConversationList]);

return conversationList;
};

export const useClickConversationCard = () => {
const [currentQueryParameters, setSearchParams] = useSearchParams();
const newQueryParameters: URLSearchParams = new URLSearchParams(
currentQueryParameters.toString(),
);

const handleClickConversation = (conversationId: string) => {
newQueryParameters.set(ChatSearchParams.ConversationId, conversationId);
setSearchParams(newQueryParameters);
};

return { handleClickConversation };
};

export const useCreateTemporaryConversation = () => {
const dispatch = useDispatch();
const { dialogId } = useGetChatSearchParams();
const { handleClickConversation } = useClickConversationCard();
let chatModel = useSelector((state: any) => state.chatModel);
let currentConversation: Pick<

const currentConversation: Pick<
IClientConversation,
'id' | 'message' | 'name' | 'dialog_id'
> = chatModel.currentConversation;
let conversationList: IClientConversation[] = chatModel.conversationList;

const createTemporaryConversation = (message: string) => {
const messages = [...(currentConversation?.message ?? [])];
const conversationList: IClientConversation[] = chatModel.conversationList;
const currentDialog: IDialog = chatModel.currentDialog;

const setCurrentConversation = useSetCurrentConversation();

const createTemporaryConversation = useCallback(() => {
const firstConversation = conversationList[0];
const messages = [...(firstConversation?.message ?? [])];
if (messages.some((x) => x.id === EmptyConversationId)) {
return;
}
messages.unshift({
messages.push({
id: EmptyConversationId,
content: message,
content: currentDialog?.prompt_config?.prologue ?? '',
role: MessageType.Assistant,
});

let nextCurrentConversation = currentConversation;

// It’s the back-end data.
if ('id' in currentConversation) {
currentConversation = { ...currentConversation, message: messages };
nextCurrentConversation = { ...currentConversation, message: messages };
} else {
// client data
currentConversation = {
nextCurrentConversation = {
id: EmptyConversationId,
name: 'New conversation',
dialog_id: dialogId,
@@ -255,23 +249,105 @@ export const useCreateTemporaryConversation = () => {

const nextConversationList = [...conversationList];

nextConversationList.push(currentConversation as IClientConversation);
nextConversationList.unshift(
nextCurrentConversation as IClientConversation,
);

dispatch({
type: 'chatModel/setCurrentConversation',
payload: currentConversation,
});
setCurrentConversation(nextCurrentConversation as IClientConversation);

dispatch({
type: 'chatModel/setConversationList',
payload: nextConversationList,
});
handleClickConversation(EmptyConversationId);
};
}, [
dispatch,
currentConversation,
dialogId,
setCurrentConversation,
handleClickConversation,
conversationList,
currentDialog,
]);

return { createTemporaryConversation };
};

export const useFetchConversationList = () => {
const dispatch = useDispatch();
const conversationList: any[] = useSelector(
(state: any) => state.chatModel.conversationList,
);
const { dialogId } = useGetChatSearchParams();

const fetchConversationList = useCallback(async () => {
if (dialogId) {
dispatch({
type: 'chatModel/listConversation',
payload: { dialog_id: dialogId },
});
}
}, [dispatch, dialogId]);

useEffect(() => {
fetchConversationList();
}, [fetchConversationList]);

return conversationList;
};

export const useSelectConversationList = () => {
const [list, setList] = useState<Array<IConversation>>([]);
let chatModel: ChatModelState = useSelector((state: any) => state.chatModel);
const { conversationList, currentDialog } = chatModel;
const { dialogId } = useGetChatSearchParams();
const prologue = currentDialog?.prompt_config?.prologue ?? '';

const addTemporaryConversation = useCallback(() => {
setList(() => {
const nextList = [
{
id: '',
name: 'New conversation',
dialog_id: dialogId,
message: [
{
content: prologue,
role: MessageType.Assistant,
},
],
} as IConversation,
...conversationList,
];
return nextList;
});
}, [conversationList, dialogId, prologue]);

useEffect(() => {
addTemporaryConversation();
}, [addTemporaryConversation]);

return { list, addTemporaryConversation };
};

export const useClickConversationCard = () => {
const [currentQueryParameters, setSearchParams] = useSearchParams();
const newQueryParameters: URLSearchParams = useMemo(
() => new URLSearchParams(currentQueryParameters.toString()),
[currentQueryParameters],
);

const handleClickConversation = useCallback(
(conversationId: string) => {
newQueryParameters.set(ChatSearchParams.ConversationId, conversationId);
setSearchParams(newQueryParameters);
},
[newQueryParameters, setSearchParams],
);

return { handleClickConversation };
};

export const useSetConversation = () => {
const dispatch = useDispatch();
const { dialogId } = useGetChatSearchParams();
@@ -302,17 +378,20 @@ export const useFetchConversation = () => {
const conversation = useSelector(
(state: any) => state.chatModel.currentConversation,
);
const setCurrentConversation = useSetCurrentConversation();

const fetchConversation = useCallback(() => {
if (conversationId !== EmptyConversationId && conversationId !== '') {
dispatch({
if (isConversationIdNotExist(conversationId)) {
dispatch<any>({
type: 'chatModel/getConversation',
payload: {
conversation_id: conversationId,
},
});
} else {
setCurrentConversation({} as IClientConversation);
}
}, [dispatch, conversationId]);
}, [dispatch, conversationId, setCurrentConversation]);

useEffect(() => {
fetchConversation();
@@ -347,7 +426,7 @@ export const useSendMessage = () => {
};

const handleSendMessage = async (message: string) => {
if (conversationId !== EmptyConversationId) {
if (conversationId !== '') {
sendMessage(message);
} else {
const data = await setConversation(message);

+ 9
- 9
web/src/pages/chat/index.tsx View File

@@ -18,11 +18,11 @@ import ChatContainer from './chat-container';
import {
useClickConversationCard,
useClickDialogCard,
useCreateTemporaryConversation,
useFetchConversationList,
useFetchDialog,
useGetChatSearchParams,
useRemoveDialog,
useSelectConversationList,
useSelectFirstDialogOnMount,
useSetCurrentDialog,
} from './hooks';
@@ -38,12 +38,10 @@ const Chat = () => {
const { handleClickDialog } = useClickDialogCard();
const { handleClickConversation } = useClickConversationCard();
const { dialogId, conversationId } = useGetChatSearchParams();
const list = useFetchConversationList(dialogId);
const { createTemporaryConversation } = useCreateTemporaryConversation();
const { list: conversationList, addTemporaryConversation } =
useSelectConversationList();
const selectedDialog = useFetchDialog(dialogId, true);
const prologue = selectedDialog?.prompt_config?.prologue || '';
useFetchDialog(dialogId, true);
const handleAppCardEnter = (id: string) => () => {
setActivated(id);
@@ -69,8 +67,8 @@ const Chat = () => {
};
const handleCreateTemporaryConversation = useCallback(() => {
createTemporaryConversation(prologue);
}, [createTemporaryConversation, prologue]);
addTemporaryConversation();
}, [addTemporaryConversation]);
const items: MenuProps['items'] = [
{
@@ -112,6 +110,8 @@ const Chat = () => {
return appItems;
};
useFetchConversationList();
return (
<Flex className={styles.chatWrapper}>
<Flex className={styles.chatAppWrapper}>
@@ -171,7 +171,7 @@ const Chat = () => {
</Flex>
<Divider></Divider>
<Flex vertical gap={10} className={styles.chatTitleContent}>
{list.map((x) => (
{conversationList.map((x) => (
<Card
key={x.id}
hoverable

+ 5
- 4
web/src/pages/chat/model.ts View File

@@ -48,10 +48,11 @@ const model: DvaModel<ChatModelState> = {
};
},
setCurrentConversation(state, { payload }) {
const messageList = payload?.message.map((x: Message | IMessage) => ({
...x,
id: 'id' in x ? x.id : uuid(),
}));
const messageList =
payload?.message?.map((x: Message | IMessage) => ({
...x,
id: 'id' in x ? x.id : uuid(),
})) ?? [];
return {
...state,
currentConversation: { ...payload, message: messageList },

+ 5
- 1
web/src/pages/chat/utils.ts View File

@@ -1,4 +1,4 @@
import { variableEnabledFieldMap } from './constants';
import { EmptyConversationId, variableEnabledFieldMap } from './constants';

export const excludeUnEnabledVariables = (values: any) => {
const unEnabledFields: Array<keyof typeof variableEnabledFieldMap> =
@@ -10,3 +10,7 @@ export const excludeUnEnabledVariables = (values: any) => {
(key) => `llm_setting.${variableEnabledFieldMap[key]}`,
);
};

export const isConversationIdNotExist = (conversationId: string) => {
return conversationId !== EmptyConversationId && conversationId !== '';
};

+ 18
- 13
web/src/pages/setting/model.ts View File

@@ -1,7 +1,7 @@
import { ITenantInfo } from '@/interfaces/database/knowledge';
import { IThirdOAIModelCollection as IThirdAiModelCollection } from '@/interfaces/database/llm';
import { IUserInfo } from '@/interfaces/database/userSetting';
import userService from '@/services/userService';
import authorizationUtil from '@/utils/authorizationUtil';
import { message } from 'antd';
import { Nullable } from 'typings';
import { DvaModel } from 'umi';
@@ -16,6 +16,7 @@ export interface SettingModelState {
llmInfo: IThirdAiModelCollection;
myLlm: any[];
factoriesList: any[];
userInfo: IUserInfo;
}
const model: DvaModel<SettingModelState> = {
@@ -30,6 +31,7 @@ const model: DvaModel<SettingModelState> = {
llmInfo: {},
myLlm: [],
factoriesList: [],
userInfo: {} as IUserInfo,
},
reducers: {
updateState(state, { payload }) {
@@ -38,10 +40,11 @@ const model: DvaModel<SettingModelState> = {
...payload,
};
},
},
subscriptions: {
setup({ dispatch, history }) {
history.listen((location) => {});
setUserInfo(state, { payload }) {
return {
...state,
userInfo: payload,
};
},
},
effects: {
@@ -63,15 +66,17 @@ const model: DvaModel<SettingModelState> = {
}
},
*getUserInfo({ payload = {} }, { call, put }) {
const { data, response } = yield call(userService.user_info, payload);
const { retcode, data: res, retmsg } = data;
const userInfo = {
avatar: res.avatar,
name: res.nickname,
email: res.email,
};
authorizationUtil.setUserInfo(userInfo);
const { data } = yield call(userService.user_info, payload);
const { retcode, data: res } = data;
// const userInfo = {
// avatar: res.avatar,
// name: res.nickname,
// email: res.email,
// };
// authorizationUtil.setUserInfo(userInfo);
if (retcode === 0) {
yield put({ type: 'setUserInfo', payload: res });
// localStorage.setItem('userInfo',res.)
}
},

Loading…
Cancel
Save