### 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
| @import url(./inter.less); | @import url(./inter.less); | ||||
| html { | |||||
| height: 100%; | |||||
| } | |||||
| body { | body { | ||||
| font-family: Inter; | font-family: Inter; | ||||
| margin: 0; | margin: 0; | ||||
| height: 100%; | |||||
| } | |||||
| #root { | |||||
| height: 100%; | |||||
| } | |||||
| .ant-app { | |||||
| height: 100%; | |||||
| } | } |
| IStats, | IStats, | ||||
| IToken, | IToken, | ||||
| } from '@/interfaces/database/chat'; | } from '@/interfaces/database/chat'; | ||||
| import { useCallback } from 'react'; | |||||
| import { useCallback, useEffect, useState } from 'react'; | |||||
| import { useDispatch, useSelector } from 'umi'; | import { useDispatch, useSelector } from 'umi'; | ||||
| export const useFetchDialogList = () => { | export const useFetchDialogList = () => { | ||||
| }; | }; | ||||
| //#endregion | //#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 |
| .messageEmpty { | .messageEmpty { | ||||
| width: 300px; | width: 300px; | ||||
| } | } | ||||
| .referenceIcon { | |||||
| padding: 0 6px; | |||||
| } | |||||
| // .referenceIcon { | |||||
| // padding: 0 6px; | |||||
| // } | |||||
| } | } | ||||
| .messageItemLeft { | .messageItemLeft { | ||||
| text-align: right; | 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; | |||||
| // } |
| import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | import { ReactComponent as AssistantIcon } from '@/assets/svg/assistant.svg'; | ||||
| import Image from '@/components/image'; | |||||
| import NewDocumentLink from '@/components/new-document-link'; | import NewDocumentLink from '@/components/new-document-link'; | ||||
| import DocumentPreviewer from '@/components/pdf-previewer'; | import DocumentPreviewer from '@/components/pdf-previewer'; | ||||
| import { MessageType } from '@/constants/chat'; | import { MessageType } from '@/constants/chat'; | ||||
| 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 { InfoCircleOutlined } from '@ant-design/icons'; | |||||
| import { | import { | ||||
| Avatar, | Avatar, | ||||
| Button, | Button, | ||||
| Flex, | Flex, | ||||
| Input, | Input, | ||||
| List, | List, | ||||
| Popover, | |||||
| Skeleton, | Skeleton, | ||||
| Space, | |||||
| Spin, | Spin, | ||||
| } from 'antd'; | } from 'antd'; | ||||
| import classNames from 'classnames'; | 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 { | import { | ||||
| useClickDrawer, | useClickDrawer, | ||||
| useFetchConversationOnMount, | useFetchConversationOnMount, | ||||
| useSelectConversationLoading, | useSelectConversationLoading, | ||||
| useSendMessage, | useSendMessage, | ||||
| } from '../hooks'; | } from '../hooks'; | ||||
| import MarkdownContent from '../markdown-content'; | |||||
| import SvgIcon from '@/components/svg-icon'; | import SvgIcon from '@/components/svg-icon'; | ||||
| import { useTranslate } from '@/hooks/commonHooks'; | import { useTranslate } from '@/hooks/commonHooks'; | ||||
| import { getExtension, isPdf } from '@/utils/documentUtils'; | import { getExtension, isPdf } from '@/utils/documentUtils'; | ||||
| import styles from './index.less'; | 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 = ({ | const MessageItem = ({ | ||||
| item, | item, | ||||
| reference, | reference, | ||||
| const isAssistant = item.role === MessageType.Assistant; | 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(() => { | const referenceDocumentList = useMemo(() => { | ||||
| return reference?.doc_aggs ?? []; | return reference?.doc_aggs ?? []; | ||||
| }, [reference?.doc_aggs]); | }, [reference?.doc_aggs]); | ||||
| <b>{isAssistant ? '' : userInfo.nickname}</b> | <b>{isAssistant ? '' : userInfo.nickname}</b> | ||||
| <div className={styles.messageText}> | <div className={styles.messageText}> | ||||
| {item.content !== '' ? ( | {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} /> | <Skeleton active className={styles.messageEmpty} /> | ||||
| )} | )} |
| import CopyToClipboard from '@/components/copy-to-clipboard'; | |||||
| import LineChart from '@/components/line-chart'; | import LineChart from '@/components/line-chart'; | ||||
| import { useCreatePublicUrlToken } from '@/hooks/chatHooks'; | |||||
| import { useSetModalState, useTranslate } from '@/hooks/commonHooks'; | import { useSetModalState, useTranslate } from '@/hooks/commonHooks'; | ||||
| import { IModalProps } from '@/interfaces/common'; | import { IModalProps } from '@/interfaces/common'; | ||||
| import { IDialog, IStats } from '@/interfaces/database/chat'; | import { IDialog, IStats } from '@/interfaces/database/chat'; | ||||
| import { ReloadOutlined } from '@ant-design/icons'; | |||||
| import { Button, Card, DatePicker, Flex, Modal, Space, Typography } from 'antd'; | import { Button, Card, DatePicker, Flex, Modal, Space, Typography } from 'antd'; | ||||
| import { RangePickerProps } from 'antd/es/date-picker'; | import { RangePickerProps } from 'antd/es/date-picker'; | ||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||
| import camelCase from 'lodash/camelCase'; | import camelCase from 'lodash/camelCase'; | ||||
| import { Link } from 'umi'; | |||||
| import ChatApiKeyModal from '../chat-api-key-modal'; | import ChatApiKeyModal from '../chat-api-key-modal'; | ||||
| import { useFetchStatsOnMount, useSelectChartStatsList } from '../hooks'; | import { useFetchStatsOnMount, useSelectChartStatsList } from '../hooks'; | ||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| }: IModalProps<any> & { dialog: IDialog }) => { | }: IModalProps<any> & { dialog: IDialog }) => { | ||||
| const { t } = useTranslate('chat'); | const { t } = useTranslate('chat'); | ||||
| const chartList = useSelectChartStatsList(); | const chartList = useSelectChartStatsList(); | ||||
| const { urlWithToken, createUrlToken, token } = useCreatePublicUrlToken( | |||||
| dialog.id, | |||||
| visible, | |||||
| ); | |||||
| const { | const { | ||||
| visible: apiKeyVisible, | visible: apiKeyVisible, | ||||
| <Card title={dialog.name}> | <Card title={dialog.name}> | ||||
| <Flex gap={8} vertical> | <Flex gap={8} vertical> | ||||
| {t('publicUrl')} | {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> | </Flex> | ||||
| <Space size={'middle'}> | |||||
| <Button>{t('preview')}</Button> | |||||
| <Button>{t('embedded')}</Button> | |||||
| </Space> | |||||
| </Card> | </Card> | ||||
| <Card title={t('backendServiceApi')}> | <Card title={t('backendServiceApi')}> | ||||
| <Flex gap={8} vertical> | <Flex gap={8} vertical> |
| type RangeValue = [Dayjs | null, Dayjs | null] | null; | type RangeValue = [Dayjs | null, Dayjs | null] | null; | ||||
| const getDay = (date: Dayjs) => date.format('YYYY-MM-DD'); | |||||
| export const useFetchStatsOnMount = (visible: boolean) => { | export const useFetchStatsOnMount = (visible: boolean) => { | ||||
| const fetchStats = useFetchStats(); | const fetchStats = useFetchStats(); | ||||
| const [pickerValue, setPickerValue] = useState<RangeValue>([ | const [pickerValue, setPickerValue] = useState<RangeValue>([ | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (visible && Array.isArray(pickerValue) && pickerValue[0]) { | 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]); | }, [fetchStats, pickerValue, visible]); | ||||
| .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; | |||||
| } |
| 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; |
| } | } | ||||
| return data; | return data; | ||||
| }, | }, | ||||
| *completeConversation({ payload }, { call, put }) { | |||||
| *completeConversation({ payload }, { call }) { | |||||
| const { data } = yield call(chatService.completeConversation, payload); | const { data } = yield call(chatService.completeConversation, payload); | ||||
| // if (data.retcode === 0) { | // if (data.retcode === 0) { | ||||
| // yield put({ | // yield put({ | ||||
| }); | }); | ||||
| message.success(i18n.t('message.created')); | message.success(i18n.t('message.created')); | ||||
| } | } | ||||
| return data.retcode; | |||||
| return data; | |||||
| }, | }, | ||||
| *listToken({ payload }, { call, put }) { | *listToken({ payload }, { call, put }) { | ||||
| const { data } = yield call(chatService.listToken, payload); | const { data } = yield call(chatService.listToken, payload); | ||||
| chatService.createExternalConversation, | chatService.createExternalConversation, | ||||
| payload, | 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 }) { | *getExternalConversation({ payload }, { call }) { | ||||
| const { data } = yield call( | const { data } = yield call( | ||||
| null, | null, | ||||
| payload, | payload, | ||||
| ); | ); | ||||
| return data.retcode; | |||||
| return data; | |||||
| }, | }, | ||||
| *completeExternalConversation({ payload }, { call }) { | *completeExternalConversation({ payload }, { call }) { | ||||
| const { data } = yield call( | const { data } = yield call( |
| .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; | |||||
| } |
| 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; |
| 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); |
| 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; |
| 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, | |||||
| }; | |||||
| }; |
| component: '@/pages/login', | component: '@/pages/login', | ||||
| layout: false, | layout: false, | ||||
| }, | }, | ||||
| { | |||||
| path: '/chat/share', | |||||
| component: '@/pages/chat/share', | |||||
| layout: false, | |||||
| }, | |||||
| { | { | ||||
| path: '/', | path: '/', | ||||
| component: '@/layouts', | component: '@/layouts', |
| }, | }, | ||||
| createExternalConversation: { | createExternalConversation: { | ||||
| url: createExternalConversation, | url: createExternalConversation, | ||||
| method: 'post', | |||||
| method: 'get', | |||||
| }, | }, | ||||
| getExternalConversation: { | getExternalConversation: { | ||||
| url: getExternalConversation, | url: getExternalConversation, |
| } | } | ||||
| return data; | return data; | ||||
| }; | }; | ||||
| export const getSearchValue = (key: string) => { | |||||
| const params = new URL(document.location as any).searchParams; | |||||
| return params.get(key); | |||||
| }; |
| 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 } from './commonUtil'; | |||||
| import { convertTheKeysOfTheObjectToSnake, getSearchValue } 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.'; // 手动中断请求。errorHandler 抛出的error message | ||||
| }); | }); | ||||
| request.interceptors.request.use((url: string, options: any) => { | 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 data = convertTheKeysOfTheObjectToSnake(options.data); | ||||
| const params = convertTheKeysOfTheObjectToSnake(options.params); | const params = convertTheKeysOfTheObjectToSnake(options.params); | ||||