### What problem does this PR solve? some chunk method pictures are not in English #437 feat: set the height of both html and body to 100% feat: add SharedChat feat: add shared hooks ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue)tags/v0.3.0
| @@ -1,6 +1,19 @@ | |||
| @import url(./inter.less); | |||
| html { | |||
| height: 100%; | |||
| } | |||
| body { | |||
| font-family: Inter; | |||
| margin: 0; | |||
| height: 100%; | |||
| } | |||
| #root { | |||
| height: 100%; | |||
| } | |||
| .ant-app { | |||
| height: 100%; | |||
| } | |||
| @@ -4,7 +4,7 @@ import { | |||
| IStats, | |||
| IToken, | |||
| } from '@/interfaces/database/chat'; | |||
| import { useCallback } from 'react'; | |||
| import { useCallback, useEffect, useState } from 'react'; | |||
| import { useDispatch, useSelector } from 'umi'; | |||
| export const useFetchDialogList = () => { | |||
| @@ -248,3 +248,78 @@ export const useSelectStats = () => { | |||
| }; | |||
| //#endregion | |||
| //#region shared chat | |||
| export const useCreateSharedConversation = () => { | |||
| const dispatch = useDispatch(); | |||
| const createSharedConversation = useCallback( | |||
| (userId?: string) => { | |||
| return dispatch<any>({ | |||
| type: 'chatModel/createExternalConversation', | |||
| payload: { userId }, | |||
| }); | |||
| }, | |||
| [dispatch], | |||
| ); | |||
| return createSharedConversation; | |||
| }; | |||
| export const useFetchSharedConversation = () => { | |||
| const dispatch = useDispatch(); | |||
| const fetchSharedConversation = useCallback( | |||
| (conversationId: string) => { | |||
| return dispatch<any>({ | |||
| type: 'chatModel/getExternalConversation', | |||
| payload: conversationId, | |||
| }); | |||
| }, | |||
| [dispatch], | |||
| ); | |||
| return fetchSharedConversation; | |||
| }; | |||
| export const useCompleteSharedConversation = () => { | |||
| const dispatch = useDispatch(); | |||
| const completeSharedConversation = useCallback( | |||
| (payload: any) => { | |||
| return dispatch<any>({ | |||
| type: 'chatModel/completeExternalConversation', | |||
| payload: payload, | |||
| }); | |||
| }, | |||
| [dispatch], | |||
| ); | |||
| return completeSharedConversation; | |||
| }; | |||
| export const useCreatePublicUrlToken = (dialogId: string, visible: boolean) => { | |||
| const [token, setToken] = useState(); | |||
| const createToken = useCreateToken(dialogId); | |||
| const { protocol, host } = window.location; | |||
| const urlWithToken = `${protocol}//${host}/chat/share?shared_id=${token}`; | |||
| const createUrlToken = useCallback(async () => { | |||
| if (visible) { | |||
| const data = await createToken(); | |||
| const urlToken = data.data?.token; | |||
| if (urlToken) { | |||
| setToken(urlToken); | |||
| } | |||
| } | |||
| }, [createToken, visible]); | |||
| useEffect(() => { | |||
| createUrlToken(); | |||
| }, [createUrlToken]); | |||
| return { token, createUrlToken, urlWithToken }; | |||
| }; | |||
| //#endregion | |||
| @@ -33,9 +33,9 @@ | |||
| .messageEmpty { | |||
| width: 300px; | |||
| } | |||
| .referenceIcon { | |||
| padding: 0 6px; | |||
| } | |||
| // .referenceIcon { | |||
| // padding: 0 6px; | |||
| // } | |||
| } | |||
| .messageItemLeft { | |||
| @@ -46,24 +46,24 @@ | |||
| text-align: right; | |||
| } | |||
| .referencePopoverWrapper { | |||
| max-width: 50vw; | |||
| } | |||
| // .referencePopoverWrapper { | |||
| // max-width: 50vw; | |||
| // } | |||
| .referenceChunkImage { | |||
| width: 10vw; | |||
| object-fit: contain; | |||
| } | |||
| // .referenceChunkImage { | |||
| // width: 10vw; | |||
| // object-fit: contain; | |||
| // } | |||
| .referenceImagePreview { | |||
| max-width: 45vw; | |||
| max-height: 45vh; | |||
| } | |||
| .chunkContentText { | |||
| .chunkText; | |||
| max-height: 45vh; | |||
| overflow-y: auto; | |||
| } | |||
| .documentLink { | |||
| padding: 0; | |||
| } | |||
| // .referenceImagePreview { | |||
| // max-width: 45vw; | |||
| // max-height: 45vh; | |||
| // } | |||
| // .chunkContentText { | |||
| // .chunkText; | |||
| // max-height: 45vh; | |||
| // overflow-y: auto; | |||
| // } | |||
| // .documentLink { | |||
| // padding: 0; | |||
| // } | |||
| @@ -1,5 +1,4 @@ | |||
| import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | |||
| import Image from '@/components/image'; | |||
| import NewDocumentLink from '@/components/new-document-link'; | |||
| import DocumentPreviewer from '@/components/pdf-previewer'; | |||
| import { MessageType } from '@/constants/chat'; | |||
| @@ -7,7 +6,6 @@ import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |||
| import { useSelectUserInfo } from '@/hooks/userSettingHook'; | |||
| import { IReference, Message } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import { InfoCircleOutlined } from '@ant-design/icons'; | |||
| import { | |||
| Avatar, | |||
| Button, | |||
| @@ -15,18 +13,11 @@ import { | |||
| Flex, | |||
| Input, | |||
| List, | |||
| Popover, | |||
| Skeleton, | |||
| Space, | |||
| Spin, | |||
| } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useCallback, useMemo } from 'react'; | |||
| import Markdown from 'react-markdown'; | |||
| import reactStringReplace from 'react-string-replace'; | |||
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; | |||
| import remarkGfm from 'remark-gfm'; | |||
| import { visitParents } from 'unist-util-visit-parents'; | |||
| import { useMemo } from 'react'; | |||
| import { | |||
| useClickDrawer, | |||
| useFetchConversationOnMount, | |||
| @@ -35,33 +26,13 @@ import { | |||
| useSelectConversationLoading, | |||
| useSendMessage, | |||
| } from '../hooks'; | |||
| import MarkdownContent from '../markdown-content'; | |||
| import SvgIcon from '@/components/svg-icon'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { getExtension, isPdf } from '@/utils/documentUtils'; | |||
| import styles from './index.less'; | |||
| const reg = /(#{2}\d+\${2})/g; | |||
| const getChunkIndex = (match: string) => Number(match.slice(2, -2)); | |||
| const rehypeWrapReference = () => { | |||
| return function wrapTextTransform(tree: any) { | |||
| visitParents(tree, 'text', (node, ancestors) => { | |||
| const latestAncestor = ancestors.at(-1); | |||
| if ( | |||
| latestAncestor.tagName !== 'custom-typography' && | |||
| latestAncestor.tagName !== 'code' | |||
| ) { | |||
| node.type = 'element'; | |||
| node.tagName = 'custom-typography'; | |||
| node.properties = {}; | |||
| node.children = [{ type: 'text', value: node.value }]; | |||
| } | |||
| }); | |||
| }; | |||
| }; | |||
| const MessageItem = ({ | |||
| item, | |||
| reference, | |||
| @@ -76,100 +47,6 @@ const MessageItem = ({ | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| const handleDocumentButtonClick = useCallback( | |||
| (documentId: string, chunk: IChunk, isPdf: boolean) => () => { | |||
| if (!isPdf) { | |||
| return; | |||
| } | |||
| clickDocumentButton(documentId, chunk); | |||
| }, | |||
| [clickDocumentButton], | |||
| ); | |||
| const getPopoverContent = useCallback( | |||
| (chunkIndex: number) => { | |||
| const chunks = reference?.chunks ?? []; | |||
| const chunkItem = chunks[chunkIndex]; | |||
| const document = reference?.doc_aggs.find( | |||
| (x) => x?.doc_id === chunkItem?.doc_id, | |||
| ); | |||
| const documentId = document?.doc_id; | |||
| const fileThumbnail = documentId ? fileThumbnails[documentId] : ''; | |||
| const fileExtension = documentId ? getExtension(document?.doc_name) : ''; | |||
| const imageId = chunkItem?.img_id; | |||
| return ( | |||
| <Flex | |||
| key={chunkItem?.chunk_id} | |||
| gap={10} | |||
| className={styles.referencePopoverWrapper} | |||
| > | |||
| {imageId && ( | |||
| <Popover | |||
| placement="left" | |||
| content={ | |||
| <Image | |||
| id={imageId} | |||
| className={styles.referenceImagePreview} | |||
| ></Image> | |||
| } | |||
| > | |||
| <Image | |||
| id={imageId} | |||
| className={styles.referenceChunkImage} | |||
| ></Image> | |||
| </Popover> | |||
| )} | |||
| <Space direction={'vertical'}> | |||
| <div | |||
| dangerouslySetInnerHTML={{ | |||
| __html: chunkItem?.content_with_weight, | |||
| }} | |||
| className={styles.chunkContentText} | |||
| ></div> | |||
| {documentId && ( | |||
| <Flex gap={'small'}> | |||
| {fileThumbnail ? ( | |||
| <img src={fileThumbnail} alt="" /> | |||
| ) : ( | |||
| <SvgIcon | |||
| name={`file-icon/${fileExtension}`} | |||
| width={24} | |||
| ></SvgIcon> | |||
| )} | |||
| <Button | |||
| type="link" | |||
| className={styles.documentLink} | |||
| onClick={handleDocumentButtonClick( | |||
| documentId, | |||
| chunkItem, | |||
| fileExtension === 'pdf', | |||
| )} | |||
| > | |||
| {document?.doc_name} | |||
| </Button> | |||
| </Flex> | |||
| )} | |||
| </Space> | |||
| </Flex> | |||
| ); | |||
| }, | |||
| [reference, fileThumbnails, handleDocumentButtonClick], | |||
| ); | |||
| const renderReference = useCallback( | |||
| (text: string) => { | |||
| return reactStringReplace(text, reg, (match, i) => { | |||
| const chunkIndex = getChunkIndex(match); | |||
| return ( | |||
| <Popover content={getPopoverContent(chunkIndex)}> | |||
| <InfoCircleOutlined key={i} className={styles.referenceIcon} /> | |||
| </Popover> | |||
| ); | |||
| }); | |||
| }, | |||
| [getPopoverContent], | |||
| ); | |||
| const referenceDocumentList = useMemo(() => { | |||
| return reference?.doc_aggs ?? []; | |||
| }, [reference?.doc_aggs]); | |||
| @@ -207,38 +84,11 @@ const MessageItem = ({ | |||
| <b>{isAssistant ? '' : userInfo.nickname}</b> | |||
| <div className={styles.messageText}> | |||
| {item.content !== '' ? ( | |||
| <Markdown | |||
| rehypePlugins={[rehypeWrapReference]} | |||
| remarkPlugins={[remarkGfm]} | |||
| components={ | |||
| { | |||
| 'custom-typography': ({ | |||
| children, | |||
| }: { | |||
| children: string; | |||
| }) => renderReference(children), | |||
| code(props: any) { | |||
| const { children, className, node, ...rest } = props; | |||
| const match = /language-(\w+)/.exec(className || ''); | |||
| return match ? ( | |||
| <SyntaxHighlighter | |||
| {...rest} | |||
| PreTag="div" | |||
| language={match[1]} | |||
| > | |||
| {String(children).replace(/\n$/, '')} | |||
| </SyntaxHighlighter> | |||
| ) : ( | |||
| <code {...rest} className={className}> | |||
| {children} | |||
| </code> | |||
| ); | |||
| }, | |||
| } as any | |||
| } | |||
| > | |||
| {item.content} | |||
| </Markdown> | |||
| <MarkdownContent | |||
| content={item.content} | |||
| reference={reference} | |||
| clickDocumentButton={clickDocumentButton} | |||
| ></MarkdownContent> | |||
| ) : ( | |||
| <Skeleton active className={styles.messageEmpty} /> | |||
| )} | |||
| @@ -1,11 +1,15 @@ | |||
| import CopyToClipboard from '@/components/copy-to-clipboard'; | |||
| import LineChart from '@/components/line-chart'; | |||
| import { useCreatePublicUrlToken } from '@/hooks/chatHooks'; | |||
| import { useSetModalState, useTranslate } from '@/hooks/commonHooks'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { IDialog, IStats } from '@/interfaces/database/chat'; | |||
| import { ReloadOutlined } from '@ant-design/icons'; | |||
| import { Button, Card, DatePicker, Flex, Modal, Space, Typography } from 'antd'; | |||
| import { RangePickerProps } from 'antd/es/date-picker'; | |||
| import dayjs from 'dayjs'; | |||
| import camelCase from 'lodash/camelCase'; | |||
| import { Link } from 'umi'; | |||
| import ChatApiKeyModal from '../chat-api-key-modal'; | |||
| import { useFetchStatsOnMount, useSelectChartStatsList } from '../hooks'; | |||
| import styles from './index.less'; | |||
| @@ -20,6 +24,10 @@ const ChatOverviewModal = ({ | |||
| }: IModalProps<any> & { dialog: IDialog }) => { | |||
| const { t } = useTranslate('chat'); | |||
| const chartList = useSelectChartStatsList(); | |||
| const { urlWithToken, createUrlToken, token } = useCreatePublicUrlToken( | |||
| dialog.id, | |||
| visible, | |||
| ); | |||
| const { | |||
| visible: apiKeyVisible, | |||
| @@ -45,14 +53,20 @@ const ChatOverviewModal = ({ | |||
| <Card title={dialog.name}> | |||
| <Flex gap={8} vertical> | |||
| {t('publicUrl')} | |||
| <Paragraph copyable className={styles.linkText}> | |||
| This is a copyable text. | |||
| </Paragraph> | |||
| <Flex className={styles.linkText} gap={10}> | |||
| <span>{urlWithToken}</span> | |||
| <CopyToClipboard text={urlWithToken}></CopyToClipboard> | |||
| <ReloadOutlined onClick={createUrlToken} /> | |||
| </Flex> | |||
| <Space size={'middle'}> | |||
| <Button> | |||
| <Link to={`/chat/share?shared_id=${token}`} target="_blank"> | |||
| {t('preview')} | |||
| </Link> | |||
| </Button> | |||
| <Button>{t('embedded')}</Button> | |||
| </Space> | |||
| </Flex> | |||
| <Space size={'middle'}> | |||
| <Button>{t('preview')}</Button> | |||
| <Button>{t('embedded')}</Button> | |||
| </Space> | |||
| </Card> | |||
| <Card title={t('backendServiceApi')}> | |||
| <Flex gap={8} vertical> | |||
| @@ -715,6 +715,8 @@ export const useGetSendButtonDisabled = () => { | |||
| type RangeValue = [Dayjs | null, Dayjs | null] | null; | |||
| const getDay = (date: Dayjs) => date.format('YYYY-MM-DD'); | |||
| export const useFetchStatsOnMount = (visible: boolean) => { | |||
| const fetchStats = useFetchStats(); | |||
| const [pickerValue, setPickerValue] = useState<RangeValue>([ | |||
| @@ -724,7 +726,10 @@ export const useFetchStatsOnMount = (visible: boolean) => { | |||
| useEffect(() => { | |||
| if (visible && Array.isArray(pickerValue) && pickerValue[0]) { | |||
| fetchStats({ fromDate: pickerValue[0], toDate: pickerValue[1] }); | |||
| fetchStats({ | |||
| fromDate: getDay(pickerValue[0]), | |||
| toDate: getDay(pickerValue[1] ?? dayjs()), | |||
| }); | |||
| } | |||
| }, [fetchStats, pickerValue, visible]); | |||
| @@ -0,0 +1,25 @@ | |||
| .referencePopoverWrapper { | |||
| max-width: 50vw; | |||
| } | |||
| .referenceChunkImage { | |||
| width: 10vw; | |||
| object-fit: contain; | |||
| } | |||
| .referenceImagePreview { | |||
| max-width: 45vw; | |||
| max-height: 45vh; | |||
| } | |||
| .chunkContentText { | |||
| .chunkText; | |||
| max-height: 45vh; | |||
| overflow-y: auto; | |||
| } | |||
| .documentLink { | |||
| padding: 0; | |||
| } | |||
| .referenceIcon { | |||
| padding: 0 6px; | |||
| } | |||
| @@ -0,0 +1,173 @@ | |||
| import Image from '@/components/image'; | |||
| import SvgIcon from '@/components/svg-icon'; | |||
| import { useSelectFileThumbnails } from '@/hooks/knowledgeHook'; | |||
| import { IReference } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import { getExtension } from '@/utils/documentUtils'; | |||
| import { InfoCircleOutlined } from '@ant-design/icons'; | |||
| import { Button, Flex, Popover, Space } from 'antd'; | |||
| import { useCallback } from 'react'; | |||
| import Markdown from 'react-markdown'; | |||
| import reactStringReplace from 'react-string-replace'; | |||
| import SyntaxHighlighter from 'react-syntax-highlighter'; | |||
| import remarkGfm from 'remark-gfm'; | |||
| import { visitParents } from 'unist-util-visit-parents'; | |||
| import styles from './index.less'; | |||
| const reg = /(#{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. | |||
| const MarkdownContent = ({ | |||
| reference, | |||
| clickDocumentButton, | |||
| content, | |||
| }: { | |||
| content: string; | |||
| reference: IReference; | |||
| clickDocumentButton: (documentId: string, chunk: IChunk) => void; | |||
| }) => { | |||
| const fileThumbnails = useSelectFileThumbnails(); | |||
| const handleDocumentButtonClick = useCallback( | |||
| (documentId: string, chunk: IChunk, isPdf: boolean) => () => { | |||
| if (!isPdf) { | |||
| return; | |||
| } | |||
| clickDocumentButton(documentId, chunk); | |||
| }, | |||
| [clickDocumentButton], | |||
| ); | |||
| const rehypeWrapReference = () => { | |||
| return function wrapTextTransform(tree: any) { | |||
| visitParents(tree, 'text', (node, ancestors) => { | |||
| const latestAncestor = ancestors.at(-1); | |||
| if ( | |||
| latestAncestor.tagName !== 'custom-typography' && | |||
| latestAncestor.tagName !== 'code' | |||
| ) { | |||
| node.type = 'element'; | |||
| node.tagName = 'custom-typography'; | |||
| node.properties = {}; | |||
| node.children = [{ type: 'text', value: node.value }]; | |||
| } | |||
| }); | |||
| }; | |||
| }; | |||
| const getPopoverContent = useCallback( | |||
| (chunkIndex: number) => { | |||
| const chunks = reference?.chunks ?? []; | |||
| const chunkItem = chunks[chunkIndex]; | |||
| const document = reference?.doc_aggs.find( | |||
| (x) => x?.doc_id === chunkItem?.doc_id, | |||
| ); | |||
| const documentId = document?.doc_id; | |||
| const fileThumbnail = documentId ? fileThumbnails[documentId] : ''; | |||
| const fileExtension = documentId ? getExtension(document?.doc_name) : ''; | |||
| const imageId = chunkItem?.img_id; | |||
| return ( | |||
| <Flex | |||
| key={chunkItem?.chunk_id} | |||
| gap={10} | |||
| className={styles.referencePopoverWrapper} | |||
| > | |||
| {imageId && ( | |||
| <Popover | |||
| placement="left" | |||
| content={ | |||
| <Image | |||
| id={imageId} | |||
| className={styles.referenceImagePreview} | |||
| ></Image> | |||
| } | |||
| > | |||
| <Image | |||
| id={imageId} | |||
| className={styles.referenceChunkImage} | |||
| ></Image> | |||
| </Popover> | |||
| )} | |||
| <Space direction={'vertical'}> | |||
| <div | |||
| dangerouslySetInnerHTML={{ | |||
| __html: chunkItem?.content_with_weight, | |||
| }} | |||
| className={styles.chunkContentText} | |||
| ></div> | |||
| {documentId && ( | |||
| <Flex gap={'small'}> | |||
| {fileThumbnail ? ( | |||
| <img src={fileThumbnail} alt="" /> | |||
| ) : ( | |||
| <SvgIcon | |||
| name={`file-icon/${fileExtension}`} | |||
| width={24} | |||
| ></SvgIcon> | |||
| )} | |||
| <Button | |||
| type="link" | |||
| className={styles.documentLink} | |||
| onClick={handleDocumentButtonClick( | |||
| documentId, | |||
| chunkItem, | |||
| fileExtension === 'pdf', | |||
| )} | |||
| > | |||
| {document?.doc_name} | |||
| </Button> | |||
| </Flex> | |||
| )} | |||
| </Space> | |||
| </Flex> | |||
| ); | |||
| }, | |||
| [reference, fileThumbnails, handleDocumentButtonClick], | |||
| ); | |||
| const renderReference = useCallback( | |||
| (text: string) => { | |||
| return reactStringReplace(text, reg, (match, i) => { | |||
| const chunkIndex = getChunkIndex(match); | |||
| return ( | |||
| <Popover content={getPopoverContent(chunkIndex)}> | |||
| <InfoCircleOutlined key={i} className={styles.referenceIcon} /> | |||
| </Popover> | |||
| ); | |||
| }); | |||
| }, | |||
| [getPopoverContent], | |||
| ); | |||
| return ( | |||
| <Markdown | |||
| rehypePlugins={[rehypeWrapReference]} | |||
| remarkPlugins={[remarkGfm]} | |||
| components={ | |||
| { | |||
| 'custom-typography': ({ children }: { children: string }) => | |||
| renderReference(children), | |||
| code(props: any) { | |||
| const { children, className, node, ...rest } = props; | |||
| const match = /language-(\w+)/.exec(className || ''); | |||
| return match ? ( | |||
| <SyntaxHighlighter {...rest} PreTag="div" language={match[1]}> | |||
| {String(children).replace(/\n$/, '')} | |||
| </SyntaxHighlighter> | |||
| ) : ( | |||
| <code {...rest} className={className}> | |||
| {children} | |||
| </code> | |||
| ); | |||
| }, | |||
| } as any | |||
| } | |||
| > | |||
| {content} | |||
| </Markdown> | |||
| ); | |||
| }; | |||
| export default MarkdownContent; | |||
| @@ -158,7 +158,7 @@ const model: DvaModel<ChatModelState> = { | |||
| } | |||
| return data; | |||
| }, | |||
| *completeConversation({ payload }, { call, put }) { | |||
| *completeConversation({ payload }, { call }) { | |||
| const { data } = yield call(chatService.completeConversation, payload); | |||
| // if (data.retcode === 0) { | |||
| // yield put({ | |||
| @@ -192,7 +192,7 @@ const model: DvaModel<ChatModelState> = { | |||
| }); | |||
| message.success(i18n.t('message.created')); | |||
| } | |||
| return data.retcode; | |||
| return data; | |||
| }, | |||
| *listToken({ payload }, { call, put }) { | |||
| const { data } = yield call(chatService.listToken, payload); | |||
| @@ -232,13 +232,13 @@ const model: DvaModel<ChatModelState> = { | |||
| chatService.createExternalConversation, | |||
| payload, | |||
| ); | |||
| if (data.retcode === 0) { | |||
| yield put({ | |||
| type: 'getExternalConversation', | |||
| payload: { conversation_id: payload.conversationId }, | |||
| }); | |||
| } | |||
| return data.retcode; | |||
| // if (data.retcode === 0) { | |||
| // yield put({ | |||
| // type: 'getExternalConversation', | |||
| // payload: data.data.id, | |||
| // }); | |||
| // } | |||
| return data; | |||
| }, | |||
| *getExternalConversation({ payload }, { call }) { | |||
| const { data } = yield call( | |||
| @@ -246,7 +246,7 @@ const model: DvaModel<ChatModelState> = { | |||
| null, | |||
| payload, | |||
| ); | |||
| return data.retcode; | |||
| return data; | |||
| }, | |||
| *completeExternalConversation({ payload }, { call }) { | |||
| const { data } = yield call( | |||
| @@ -0,0 +1,50 @@ | |||
| .chatWrapper { | |||
| height: 100%; | |||
| } | |||
| .chatContainer { | |||
| padding: 10px; | |||
| box-sizing: border-box; | |||
| height: 100%; | |||
| .messageContainer { | |||
| overflow-y: auto; | |||
| padding-right: 6px; | |||
| } | |||
| } | |||
| .messageItem { | |||
| padding: 24px 0; | |||
| .messageItemSection { | |||
| display: inline-block; | |||
| } | |||
| .messageItemSectionLeft { | |||
| width: 70%; | |||
| } | |||
| .messageItemSectionRight { | |||
| width: 40%; | |||
| } | |||
| .messageItemContent { | |||
| display: inline-flex; | |||
| gap: 20px; | |||
| } | |||
| .messageItemContentReverse { | |||
| flex-direction: row-reverse; | |||
| } | |||
| .messageText { | |||
| .chunkText(); | |||
| padding: 0 14px; | |||
| background-color: rgba(249, 250, 251, 1); | |||
| word-break: break-all; | |||
| } | |||
| .messageEmpty { | |||
| width: 300px; | |||
| } | |||
| } | |||
| .messageItemLeft { | |||
| text-align: left; | |||
| } | |||
| .messageItemRight { | |||
| text-align: right; | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| 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> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default SharedChat; | |||
| @@ -0,0 +1,122 @@ | |||
| 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 classNames from 'classnames'; | |||
| import { useSelectConversationLoading } from '../hooks'; | |||
| import React, { ChangeEventHandler, forwardRef } from 'react'; | |||
| import { IClientConversation } from '../interface'; | |||
| import styles from './index.less'; | |||
| import SharedMarkdown from './shared-markdown'; | |||
| const MessageItem = ({ item }: { item: Message }) => { | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| return ( | |||
| <div | |||
| className={classNames(styles.messageItem, { | |||
| [styles.messageItemLeft]: item.role === MessageType.Assistant, | |||
| [styles.messageItemRight]: item.role === MessageType.User, | |||
| })} | |||
| > | |||
| <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 ? ( | |||
| <Avatar | |||
| size={40} | |||
| src={ | |||
| 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png' | |||
| } | |||
| /> | |||
| ) : ( | |||
| <AssistantIcon></AssistantIcon> | |||
| )} | |||
| <Flex vertical gap={8} flex={1}> | |||
| <b>{isAssistant ? '' : 'You'}</b> | |||
| <div className={styles.messageText}> | |||
| {item.content !== '' ? ( | |||
| <SharedMarkdown content={item.content}></SharedMarkdown> | |||
| ) : ( | |||
| <Skeleton active className={styles.messageEmpty} /> | |||
| )} | |||
| </div> | |||
| </Flex> | |||
| </div> | |||
| </section> | |||
| </div> | |||
| ); | |||
| }; | |||
| interface IProps { | |||
| handlePressEnter(): void; | |||
| handleInputChange: ChangeEventHandler<HTMLInputElement>; | |||
| value: string; | |||
| loading: boolean; | |||
| sendLoading: boolean; | |||
| conversation: IClientConversation; | |||
| ref: React.LegacyRef<any>; | |||
| } | |||
| const ChatContainer = ( | |||
| { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading: sendLoading, | |||
| conversation, | |||
| }: IProps, | |||
| ref: React.LegacyRef<any>, | |||
| ) => { | |||
| const loading = useSelectConversationLoading(); | |||
| const { t } = useTranslate('chat'); | |||
| return ( | |||
| <> | |||
| <Flex flex={1} className={styles.chatContainer} vertical> | |||
| <Flex flex={1} vertical className={styles.messageContainer}> | |||
| <div> | |||
| <Spin spinning={loading}> | |||
| {conversation?.message?.map((message) => { | |||
| return ( | |||
| <MessageItem key={message.id} item={message}></MessageItem> | |||
| ); | |||
| })} | |||
| </Spin> | |||
| </div> | |||
| <div ref={ref} /> | |||
| </Flex> | |||
| <Input | |||
| size="large" | |||
| placeholder={t('sendPlaceholder')} | |||
| value={value} | |||
| // disabled={disabled} | |||
| suffix={ | |||
| <Button | |||
| type="primary" | |||
| onClick={handlePressEnter} | |||
| loading={sendLoading} | |||
| // disabled={disabled} | |||
| > | |||
| {t('send')} | |||
| </Button> | |||
| } | |||
| onPressEnter={handlePressEnter} | |||
| onChange={handleInputChange} | |||
| /> | |||
| </Flex> | |||
| </> | |||
| ); | |||
| }; | |||
| export default forwardRef(ChatContainer); | |||
| @@ -0,0 +1,32 @@ | |||
| import Markdown from 'react-markdown'; | |||
| import SyntaxHighlighter from 'react-syntax-highlighter'; | |||
| import remarkGfm from 'remark-gfm'; | |||
| const SharedMarkdown = ({ content }: { content: string }) => { | |||
| return ( | |||
| <Markdown | |||
| remarkPlugins={[remarkGfm]} | |||
| components={ | |||
| { | |||
| code(props: any) { | |||
| const { children, className, node, ...rest } = props; | |||
| const match = /language-(\w+)/.exec(className || ''); | |||
| return match ? ( | |||
| <SyntaxHighlighter {...rest} PreTag="div" language={match[1]}> | |||
| {String(children).replace(/\n$/, '')} | |||
| </SyntaxHighlighter> | |||
| ) : ( | |||
| <code {...rest} className={className}> | |||
| {children} | |||
| </code> | |||
| ); | |||
| }, | |||
| } as any | |||
| } | |||
| > | |||
| {content} | |||
| </Markdown> | |||
| ); | |||
| }; | |||
| export default SharedMarkdown; | |||
| @@ -0,0 +1,192 @@ | |||
| import { MessageType } from '@/constants/chat'; | |||
| import { | |||
| useCompleteSharedConversation, | |||
| useCreateSharedConversation, | |||
| useFetchSharedConversation, | |||
| } from '@/hooks/chatHooks'; | |||
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | |||
| import omit from 'lodash/omit'; | |||
| import { | |||
| Dispatch, | |||
| SetStateAction, | |||
| useCallback, | |||
| useEffect, | |||
| useState, | |||
| } from 'react'; | |||
| import { useSearchParams } from 'umi'; | |||
| import { v4 as uuid } from 'uuid'; | |||
| import { useHandleMessageInputChange, useScrollToBottom } from './hooks'; | |||
| import { IClientConversation, IMessage } from './interface'; | |||
| export const useCreateSharedConversationOnMount = () => { | |||
| const [currentQueryParameters] = useSearchParams(); | |||
| const [conversationId, setConversationId] = useState(''); | |||
| const createConversation = useCreateSharedConversation(); | |||
| const sharedId = currentQueryParameters.get('shared_id'); | |||
| const userId = currentQueryParameters.get('user_id'); | |||
| const setConversation = useCallback(async () => { | |||
| console.info(sharedId); | |||
| if (sharedId) { | |||
| const data = await createConversation(userId ?? undefined); | |||
| const id = data.data?.id; | |||
| if (id) { | |||
| setConversationId(id); | |||
| } | |||
| } | |||
| }, [createConversation, sharedId, userId]); | |||
| useEffect(() => { | |||
| setConversation(); | |||
| }, [setConversation]); | |||
| return { conversationId }; | |||
| }; | |||
| export const useSelectCurrentSharedConversation = (conversationId: string) => { | |||
| const [currentConversation, setCurrentConversation] = | |||
| useState<IClientConversation>({} as IClientConversation); | |||
| const fetchConversation = useFetchSharedConversation(); | |||
| const loading = useOneNamespaceEffectsLoading('chatModel', [ | |||
| 'getExternalConversation', | |||
| ]); | |||
| const ref = useScrollToBottom(currentConversation); | |||
| const addNewestConversation = useCallback((message: string) => { | |||
| 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 removeLatestMessage = useCallback(() => { | |||
| setCurrentConversation((pre) => { | |||
| const nextMessages = pre.message.slice(0, -2); | |||
| return { | |||
| ...pre, | |||
| message: nextMessages, | |||
| }; | |||
| }); | |||
| }, []); | |||
| const fetchConversationOnMount = useCallback(async () => { | |||
| if (conversationId) { | |||
| const data = await fetchConversation(conversationId); | |||
| if (data.retcode === 0) { | |||
| setCurrentConversation(data.data); | |||
| } | |||
| } | |||
| }, [conversationId, fetchConversation]); | |||
| useEffect(() => { | |||
| fetchConversationOnMount(); | |||
| }, [fetchConversationOnMount]); | |||
| return { | |||
| currentConversation, | |||
| addNewestConversation, | |||
| removeLatestMessage, | |||
| loading, | |||
| ref, | |||
| setCurrentConversation, | |||
| }; | |||
| }; | |||
| export const useSendSharedMessage = ( | |||
| conversation: IClientConversation, | |||
| addNewestConversation: (message: string) => void, | |||
| removeLatestMessage: () => void, | |||
| setCurrentConversation: Dispatch<SetStateAction<IClientConversation>>, | |||
| ) => { | |||
| const conversationId = conversation.id; | |||
| const loading = useOneNamespaceEffectsLoading('chatModel', [ | |||
| 'completeExternalConversation', | |||
| ]); | |||
| const setConversation = useCreateSharedConversation(); | |||
| const { handleInputChange, value, setValue } = useHandleMessageInputChange(); | |||
| const fetchConversation = useFetchSharedConversation(); | |||
| const completeConversation = useCompleteSharedConversation(); | |||
| const sendMessage = useCallback( | |||
| async (message: string, id?: string) => { | |||
| const retcode = await completeConversation({ | |||
| conversation_id: id ?? conversationId, | |||
| messages: [ | |||
| ...(conversation?.message ?? []).map((x: IMessage) => omit(x, 'id')), | |||
| { | |||
| role: MessageType.User, | |||
| content: message, | |||
| }, | |||
| ], | |||
| }); | |||
| if (retcode === 0) { | |||
| const data = await fetchConversation(conversationId); | |||
| if (data.retcode === 0) { | |||
| setCurrentConversation(data.data); | |||
| } | |||
| } else { | |||
| // cancel loading | |||
| setValue(message); | |||
| removeLatestMessage(); | |||
| } | |||
| }, | |||
| [ | |||
| conversationId, | |||
| conversation?.message, | |||
| fetchConversation, | |||
| removeLatestMessage, | |||
| setValue, | |||
| completeConversation, | |||
| setCurrentConversation, | |||
| ], | |||
| ); | |||
| const handleSendMessage = useCallback( | |||
| async (message: string) => { | |||
| if (conversationId !== '') { | |||
| sendMessage(message); | |||
| } else { | |||
| const data = await setConversation('user id'); | |||
| if (data.retcode === 0) { | |||
| const id = data.data.id; | |||
| sendMessage(message, id); | |||
| } | |||
| } | |||
| }, | |||
| [conversationId, setConversation, sendMessage], | |||
| ); | |||
| const handlePressEnter = () => { | |||
| if (!loading) { | |||
| setValue(''); | |||
| addNewestConversation(value); | |||
| handleSendMessage(value.trim()); | |||
| } | |||
| }; | |||
| return { | |||
| handlePressEnter, | |||
| handleInputChange, | |||
| value, | |||
| loading, | |||
| }; | |||
| }; | |||
| @@ -4,6 +4,11 @@ const routes = [ | |||
| component: '@/pages/login', | |||
| layout: false, | |||
| }, | |||
| { | |||
| path: '/chat/share', | |||
| component: '@/pages/chat/share', | |||
| layout: false, | |||
| }, | |||
| { | |||
| path: '/', | |||
| component: '@/layouts', | |||
| @@ -76,7 +76,7 @@ const methods = { | |||
| }, | |||
| createExternalConversation: { | |||
| url: createExternalConversation, | |||
| method: 'post', | |||
| method: 'get', | |||
| }, | |||
| getExternalConversation: { | |||
| url: getExternalConversation, | |||
| @@ -15,3 +15,8 @@ export const convertTheKeysOfTheObjectToSnake = (data: unknown) => { | |||
| } | |||
| return data; | |||
| }; | |||
| export const getSearchValue = (key: string) => { | |||
| const params = new URL(document.location as any).searchParams; | |||
| return params.get(key); | |||
| }; | |||
| @@ -4,7 +4,7 @@ import authorizationUtil from '@/utils/authorizationUtil'; | |||
| import { message, notification } from 'antd'; | |||
| import { history } from 'umi'; | |||
| import { RequestMethod, extend } from 'umi-request'; | |||
| import { convertTheKeysOfTheObjectToSnake } from './commonUtil'; | |||
| import { convertTheKeysOfTheObjectToSnake, getSearchValue } from './commonUtil'; | |||
| const ABORT_REQUEST_ERR_MESSAGE = 'The user aborted a request.'; // 手动中断请求。errorHandler 抛出的error message | |||
| @@ -87,7 +87,10 @@ const request: RequestMethod = extend({ | |||
| }); | |||
| request.interceptors.request.use((url: string, options: any) => { | |||
| const authorization = authorizationUtil.getAuthorization(); | |||
| const sharedId = getSearchValue('shared_id'); | |||
| const authorization = sharedId | |||
| ? 'Bearer ' + sharedId | |||
| : authorizationUtil.getAuthorization(); | |||
| const data = convertTheKeysOfTheObjectToSnake(options.data); | |||
| const params = convertTheKeysOfTheObjectToSnake(options.params); | |||