### What problem does this PR solve? Embed the chat window into other websites through iframe #345 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.3.0
| @@ -2,7 +2,11 @@ import Markdown from 'react-markdown'; | |||
| import SyntaxHighlighter from 'react-syntax-highlighter'; | |||
| import remarkGfm from 'remark-gfm'; | |||
| const SharedMarkdown = ({ content }: { content: string }) => { | |||
| const HightLightMarkdown = ({ | |||
| children, | |||
| }: { | |||
| children: string | null | undefined; | |||
| }) => { | |||
| return ( | |||
| <Markdown | |||
| remarkPlugins={[remarkGfm]} | |||
| @@ -24,9 +28,9 @@ const SharedMarkdown = ({ content }: { content: string }) => { | |||
| } as any | |||
| } | |||
| > | |||
| {content} | |||
| {children} | |||
| </Markdown> | |||
| ); | |||
| }; | |||
| export default SharedMarkdown; | |||
| export default HightLightMarkdown; | |||
| @@ -4,7 +4,7 @@ import { | |||
| IStats, | |||
| IToken, | |||
| } from '@/interfaces/database/chat'; | |||
| import { useCallback, useEffect, useState } from 'react'; | |||
| import { useCallback } from 'react'; | |||
| import { useDispatch, useSelector } from 'umi'; | |||
| export const useFetchDialogList = () => { | |||
| @@ -299,27 +299,4 @@ export const useCompleteSharedConversation = () => { | |||
| 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,3 +33,12 @@ | |||
| .pointerCursor() { | |||
| cursor: pointer; | |||
| } | |||
| .clearCardBody() { | |||
| :global { | |||
| .ant-card-body { | |||
| padding: 0; | |||
| margin: 0; | |||
| } | |||
| } | |||
| } | |||
| @@ -349,7 +349,7 @@ export default { | |||
| 'This sets the maximum length of the model’s output, measured in the number of tokens (words or pieces of words).', | |||
| quote: 'Show Quote', | |||
| quoteTip: 'Should the source of the original text be displayed?', | |||
| overview: 'Overview', | |||
| overview: 'API', | |||
| pv: 'Number of messages', | |||
| uv: 'Active user number', | |||
| speed: 'Token output speed', | |||
| @@ -367,6 +367,14 @@ export default { | |||
| createNewKey: 'Create new key', | |||
| created: 'Created', | |||
| action: 'Action', | |||
| embedModalTitle: 'Embed into website', | |||
| comingSoon: 'Coming Soon', | |||
| fullScreenTitle: 'Full Embed', | |||
| fullScreenDescription: | |||
| 'Embed the following iframe into your website at the desired location', | |||
| partialTitle: 'Partial Embed', | |||
| extensionTitle: 'Chrome Extension', | |||
| tokenError: 'Please create API Token first!', | |||
| }, | |||
| setting: { | |||
| profile: 'Profile', | |||
| @@ -321,7 +321,7 @@ export default { | |||
| '這設置了模型輸出的最大長度,以標記(單詞或單詞片段)的數量來衡量。', | |||
| quote: '顯示引文', | |||
| quoteTip: '是否應該顯示原文出處?', | |||
| overview: '概覽', | |||
| overview: 'API', | |||
| pv: '消息數', | |||
| uv: '活躍用戶數', | |||
| speed: 'Token 輸出速度', | |||
| @@ -339,6 +339,13 @@ export default { | |||
| createNewKey: '創建新密鑰', | |||
| created: '創建於', | |||
| action: '操作', | |||
| embedModalTitle: '嵌入網站', | |||
| comingSoon: '即將推出', | |||
| fullScreenTitle: '全屏嵌入', | |||
| fullScreenDescription: '將以下iframe嵌入您的網站處於所需位置', | |||
| partialTitle: '部分嵌入', | |||
| extensionTitle: 'Chrome 插件', | |||
| tokenError: '請先創建 Api Token!', | |||
| }, | |||
| setting: { | |||
| profile: '概述', | |||
| @@ -338,7 +338,7 @@ export default { | |||
| '这设置了模型输出的最大长度,以标记(单词或单词片段)的数量来衡量。', | |||
| quote: '显示引文', | |||
| quoteTip: '是否应该显示原文出处?', | |||
| overview: '概览', | |||
| overview: 'API', | |||
| pv: '消息数', | |||
| uv: '活跃用户数', | |||
| speed: 'Token 输出速度', | |||
| @@ -356,6 +356,13 @@ export default { | |||
| createNewKey: '创建新密钥', | |||
| created: '创建于', | |||
| action: '操作', | |||
| embedModalTitle: '嵌入网站', | |||
| comingSoon: '即将推出', | |||
| fullScreenTitle: '全屏嵌入', | |||
| fullScreenDescription: '将以下iframe嵌入您的网站处于所需位置', | |||
| partialTitle: '部分嵌入', | |||
| extensionTitle: 'Chrome 插件', | |||
| tokenError: '请先创建 Api Token!', | |||
| }, | |||
| setting: { | |||
| profile: '概要', | |||
| @@ -1,17 +1,19 @@ | |||
| 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 EmbedModal from '../embed-modal'; | |||
| import { | |||
| useFetchStatsOnMount, | |||
| usePreviewChat, | |||
| useSelectChartStatsList, | |||
| useShowEmbedModal, | |||
| } from '../hooks'; | |||
| import styles from './index.less'; | |||
| const { Paragraph } = Typography; | |||
| @@ -24,16 +26,18 @@ const ChatOverviewModal = ({ | |||
| }: IModalProps<any> & { dialog: IDialog }) => { | |||
| const { t } = useTranslate('chat'); | |||
| const chartList = useSelectChartStatsList(); | |||
| const { urlWithToken, createUrlToken, token } = useCreatePublicUrlToken( | |||
| dialog.id, | |||
| visible, | |||
| ); | |||
| const { | |||
| visible: apiKeyVisible, | |||
| hideModal: hideApiKeyModal, | |||
| showModal: showApiKeyModal, | |||
| } = useSetModalState(); | |||
| const { | |||
| embedVisible, | |||
| hideEmbedModal, | |||
| showEmbedModal, | |||
| embedToken, | |||
| errorContextHolder, | |||
| } = useShowEmbedModal(dialog.id); | |||
| const { pickerValue, setPickerValue } = useFetchStatsOnMount(visible); | |||
| @@ -41,6 +45,8 @@ const ChatOverviewModal = ({ | |||
| return current && current > dayjs().endOf('day'); | |||
| }; | |||
| const { handlePreview, contextHolder } = usePreviewChat(dialog.id); | |||
| return ( | |||
| <> | |||
| <Modal | |||
| @@ -50,36 +56,41 @@ const ChatOverviewModal = ({ | |||
| width={'100vw'} | |||
| > | |||
| <Flex vertical gap={'middle'}> | |||
| <Card title={dialog.name}> | |||
| <Flex gap={8} vertical> | |||
| {t('publicUrl')} | |||
| <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> | |||
| </Card> | |||
| <Card title={t('backendServiceApi')}> | |||
| <Flex gap={8} vertical> | |||
| {t('serviceApiEndpoint')} | |||
| <Paragraph copyable className={styles.linkText}> | |||
| This is a copyable text. | |||
| https://demo.ragflow.io/v1/api/ | |||
| </Paragraph> | |||
| </Flex> | |||
| <Space size={'middle'}> | |||
| <Button onClick={showApiKeyModal}>{t('apiKey')}</Button> | |||
| <Button>{t('apiReference')}</Button> | |||
| <a | |||
| href={ | |||
| 'https://github.com/infiniflow/ragflow/blob/main/docs/conversation_api.md' | |||
| } | |||
| target="_blank" | |||
| rel="noreferrer" | |||
| > | |||
| <Button>{t('apiReference')}</Button> | |||
| </a> | |||
| </Space> | |||
| </Card> | |||
| <Card title={dialog.name}> | |||
| <Flex gap={8} vertical> | |||
| {t('publicUrl')} | |||
| {/* <Flex className={styles.linkText} gap={10}> | |||
| <span>{urlWithToken}</span> | |||
| <CopyToClipboard text={urlWithToken}></CopyToClipboard> | |||
| <ReloadOutlined onClick={createUrlToken} /> | |||
| </Flex> */} | |||
| <Space size={'middle'}> | |||
| <Button onClick={handlePreview}>{t('preview')}</Button> | |||
| <Button onClick={showEmbedModal}>{t('embedded')}</Button> | |||
| </Space> | |||
| </Flex> | |||
| </Card> | |||
| <Space> | |||
| <b>{t('dateRange')}</b> | |||
| <RangePicker | |||
| @@ -103,6 +114,13 @@ const ChatOverviewModal = ({ | |||
| hideModal={hideApiKeyModal} | |||
| dialogId={dialog.id} | |||
| ></ChatApiKeyModal> | |||
| <EmbedModal | |||
| token={embedToken} | |||
| visible={embedVisible} | |||
| hideModal={hideEmbedModal} | |||
| ></EmbedModal> | |||
| {contextHolder} | |||
| {errorContextHolder} | |||
| </Modal> | |||
| </> | |||
| ); | |||
| @@ -0,0 +1,8 @@ | |||
| .codeCard { | |||
| .clearCardBody(); | |||
| } | |||
| .codeText { | |||
| padding: 10px; | |||
| background-color: #e8e8ea; | |||
| } | |||
| @@ -0,0 +1,70 @@ | |||
| import CopyToClipboard from '@/components/copy-to-clipboard'; | |||
| import HightLightMarkdown from '@/components/highlight-markdown'; | |||
| import { useTranslate } from '@/hooks/commonHooks'; | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { Card, Modal, Tabs, TabsProps } from 'antd'; | |||
| import styles from './index.less'; | |||
| const EmbedModal = ({ | |||
| visible, | |||
| hideModal, | |||
| token = '', | |||
| }: IModalProps<any> & { token: string }) => { | |||
| const { t } = useTranslate('chat'); | |||
| const text = ` | |||
| ~~~ html | |||
| <iframe | |||
| src="https://demo.ragflow.io/chat/share?shared_id=${token}" | |||
| style="width: 100%; height: 100%; min-height: 600px" | |||
| frameborder="0" | |||
| > | |||
| </iframe> | |||
| ~~~ | |||
| `; | |||
| const items: TabsProps['items'] = [ | |||
| { | |||
| key: '1', | |||
| label: t('fullScreenTitle'), | |||
| children: ( | |||
| <Card | |||
| title={t('fullScreenDescription')} | |||
| extra={<CopyToClipboard text={text}></CopyToClipboard>} | |||
| className={styles.codeCard} | |||
| > | |||
| <HightLightMarkdown>{text}</HightLightMarkdown> | |||
| </Card> | |||
| ), | |||
| }, | |||
| { | |||
| key: '2', | |||
| label: t('partialTitle'), | |||
| children: t('comingSoon'), | |||
| }, | |||
| { | |||
| key: '3', | |||
| label: t('extensionTitle'), | |||
| children: t('comingSoon'), | |||
| }, | |||
| ]; | |||
| const onChange = (key: string) => { | |||
| console.log(key); | |||
| }; | |||
| return ( | |||
| <Modal | |||
| title={t('embedModalTitle')} | |||
| open={visible} | |||
| style={{ top: 300 }} | |||
| width={'50vw'} | |||
| onOk={hideModal} | |||
| onCancel={hideModal} | |||
| > | |||
| <Tabs defaultActiveKey="1" items={items} onChange={onChange} /> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| export default EmbedModal; | |||
| @@ -14,15 +14,21 @@ import { | |||
| useRemoveToken, | |||
| useSelectConversationList, | |||
| useSelectDialogList, | |||
| useSelectStats, | |||
| useSelectTokenList, | |||
| useSetDialog, | |||
| useUpdateConversation, | |||
| } from '@/hooks/chatHooks'; | |||
| import { useSetModalState, useShowDeleteConfirm } from '@/hooks/commonHooks'; | |||
| import { | |||
| useSetModalState, | |||
| useShowDeleteConfirm, | |||
| useTranslate, | |||
| } from '@/hooks/commonHooks'; | |||
| import { useOneNamespaceEffectsLoading } from '@/hooks/storeHooks'; | |||
| import { IConversation, IDialog, IStats } from '@/interfaces/database/chat'; | |||
| import { IChunk } from '@/interfaces/database/knowledge'; | |||
| import { getFileExtension } from '@/utils'; | |||
| import { message } from 'antd'; | |||
| import dayjs, { Dayjs } from 'dayjs'; | |||
| import omit from 'lodash/omit'; | |||
| import { | |||
| @@ -777,35 +783,35 @@ type ChartStatsType = { | |||
| }; | |||
| export const useSelectChartStatsList = (): ChartStatsType => { | |||
| // const stats: IStats = useSelectStats(); | |||
| const stats = { | |||
| pv: [ | |||
| ['2024-06-01', 1], | |||
| ['2024-07-24', 3], | |||
| ['2024-09-01', 10], | |||
| ], | |||
| uv: [ | |||
| ['2024-02-01', 0], | |||
| ['2024-03-01', 99], | |||
| ['2024-05-01', 3], | |||
| ], | |||
| speed: [ | |||
| ['2024-09-01', 2], | |||
| ['2024-09-01', 3], | |||
| ], | |||
| tokens: [ | |||
| ['2024-09-01', 1], | |||
| ['2024-09-01', 3], | |||
| ], | |||
| round: [ | |||
| ['2024-09-01', 0], | |||
| ['2024-09-01', 3], | |||
| ], | |||
| thumb_up: [ | |||
| ['2024-09-01', 3], | |||
| ['2024-09-01', 9], | |||
| ], | |||
| }; | |||
| const stats: IStats = useSelectStats(); | |||
| // const stats = { | |||
| // pv: [ | |||
| // ['2024-06-01', 1], | |||
| // ['2024-07-24', 3], | |||
| // ['2024-09-01', 10], | |||
| // ], | |||
| // uv: [ | |||
| // ['2024-02-01', 0], | |||
| // ['2024-03-01', 99], | |||
| // ['2024-05-01', 3], | |||
| // ], | |||
| // speed: [ | |||
| // ['2024-09-01', 2], | |||
| // ['2024-09-01', 3], | |||
| // ], | |||
| // tokens: [ | |||
| // ['2024-09-01', 1], | |||
| // ['2024-09-01', 3], | |||
| // ], | |||
| // round: [ | |||
| // ['2024-09-01', 0], | |||
| // ['2024-09-01', 3], | |||
| // ], | |||
| // thumb_up: [ | |||
| // ['2024-09-01', 3], | |||
| // ['2024-09-01', 9], | |||
| // ], | |||
| // }; | |||
| return Object.keys(stats).reduce((pre, cur) => { | |||
| const item = stats[cur as keyof IStats]; | |||
| @@ -819,4 +825,93 @@ export const useSelectChartStatsList = (): ChartStatsType => { | |||
| }, {} as ChartStatsType); | |||
| }; | |||
| export const useShowTokenEmptyError = () => { | |||
| const [messageApi, contextHolder] = message.useMessage(); | |||
| const { t } = useTranslate('chat'); | |||
| const showTokenEmptyError = useCallback(() => { | |||
| messageApi.error(t('tokenError')); | |||
| }, [messageApi, t]); | |||
| return { showTokenEmptyError, contextHolder }; | |||
| }; | |||
| const getUrlWithToken = (token: string) => { | |||
| const { protocol, host } = window.location; | |||
| return `${protocol}//${host}/chat/share?shared_id=${token}`; | |||
| }; | |||
| const useFetchTokenListBeforeOtherStep = (dialogId: string) => { | |||
| const { showTokenEmptyError, contextHolder } = useShowTokenEmptyError(); | |||
| const listToken = useListToken(); | |||
| const tokenList = useSelectTokenList(); | |||
| const token = | |||
| Array.isArray(tokenList) && tokenList.length > 0 ? tokenList[0].token : ''; | |||
| const handleOperate = useCallback(async () => { | |||
| const data = await listToken(dialogId); | |||
| const list = data.data; | |||
| if (data.retcode === 0 && Array.isArray(list) && list.length > 0) { | |||
| return list[0]?.token; | |||
| } else { | |||
| showTokenEmptyError(); | |||
| return false; | |||
| } | |||
| }, [dialogId, listToken, showTokenEmptyError]); | |||
| return { | |||
| token, | |||
| contextHolder, | |||
| handleOperate, | |||
| }; | |||
| }; | |||
| export const useShowEmbedModal = (dialogId: string) => { | |||
| const { | |||
| visible: embedVisible, | |||
| hideModal: hideEmbedModal, | |||
| showModal: showEmbedModal, | |||
| } = useSetModalState(); | |||
| const { handleOperate, token, contextHolder } = | |||
| useFetchTokenListBeforeOtherStep(dialogId); | |||
| const handleShowEmbedModal = useCallback(async () => { | |||
| const succeed = await handleOperate(); | |||
| if (succeed) { | |||
| showEmbedModal(); | |||
| } | |||
| }, [handleOperate, showEmbedModal]); | |||
| return { | |||
| showEmbedModal: handleShowEmbedModal, | |||
| hideEmbedModal, | |||
| embedVisible, | |||
| embedToken: token, | |||
| errorContextHolder: contextHolder, | |||
| }; | |||
| }; | |||
| export const usePreviewChat = (dialogId: string) => { | |||
| const { handleOperate, contextHolder } = | |||
| useFetchTokenListBeforeOtherStep(dialogId); | |||
| const open = useCallback((t: string) => { | |||
| window.open(getUrlWithToken(t), '_blank'); | |||
| }, []); | |||
| const handlePreview = useCallback(async () => { | |||
| const token = await handleOperate(); | |||
| if (token) { | |||
| open(token); | |||
| } | |||
| }, [handleOperate, open]); | |||
| return { | |||
| handlePreview, | |||
| contextHolder, | |||
| }; | |||
| }; | |||
| //#endregion | |||
| @@ -1,6 +1,11 @@ | |||
| import { ReactComponent as ChatAppCube } from '@/assets/svg/chat-app-cube.svg'; | |||
| import RenameModal from '@/components/rename-modal'; | |||
| import { DeleteOutlined, EditOutlined, FormOutlined } from '@ant-design/icons'; | |||
| import { | |||
| CloudOutlined, | |||
| DeleteOutlined, | |||
| EditOutlined, | |||
| FormOutlined, | |||
| } from '@ant-design/icons'; | |||
| import { | |||
| Avatar, | |||
| Button, | |||
| @@ -185,16 +190,16 @@ const Chat = () => { | |||
| ), | |||
| }, | |||
| { type: 'divider' }, | |||
| // { | |||
| // key: '3', | |||
| // onClick: handleShowOverviewModal(dialog), | |||
| // label: ( | |||
| // <Space> | |||
| // <ProfileOutlined /> | |||
| // {t('overview')} | |||
| // </Space> | |||
| // ), | |||
| // }, | |||
| { | |||
| key: '3', | |||
| onClick: handleShowOverviewModal(dialog), | |||
| label: ( | |||
| <Space> | |||
| <CloudOutlined /> | |||
| {t('overview')} | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return appItems; | |||
| @@ -202,7 +202,7 @@ const model: DvaModel<ChatModelState> = { | |||
| payload: data.data, | |||
| }); | |||
| } | |||
| return data.retcode; | |||
| return data; | |||
| }, | |||
| *removeToken({ payload }, { call, put }) { | |||
| const { data } = yield call( | |||
| @@ -6,10 +6,10 @@ import { Avatar, Button, Flex, Input, Skeleton, Spin } from 'antd'; | |||
| import classNames from 'classnames'; | |||
| import { useSelectConversationLoading } from '../hooks'; | |||
| import HightLightMarkdown from '@/components/highlight-markdown'; | |||
| import React, { ChangeEventHandler, forwardRef } from 'react'; | |||
| import { IClientConversation } from '../interface'; | |||
| import styles from './index.less'; | |||
| import SharedMarkdown from './shared-markdown'; | |||
| const MessageItem = ({ item }: { item: Message }) => { | |||
| const isAssistant = item.role === MessageType.Assistant; | |||
| @@ -46,7 +46,7 @@ const MessageItem = ({ item }: { item: Message }) => { | |||
| <b>{isAssistant ? '' : 'You'}</b> | |||
| <div className={styles.messageText}> | |||
| {item.content !== '' ? ( | |||
| <SharedMarkdown content={item.content}></SharedMarkdown> | |||
| <HightLightMarkdown>{item.content}</HightLightMarkdown> | |||
| ) : ( | |||
| <Skeleton active className={styles.messageEmpty} /> | |||
| )} | |||
| @@ -98,8 +98,8 @@ request.interceptors.request.use((url: string, options: any) => { | |||
| url, | |||
| options: { | |||
| ...options, | |||
| // data, | |||
| // params, | |||
| data, | |||
| params, | |||
| headers: { | |||
| ...(options.skipToken ? undefined : { [Authorization]: authorization }), | |||
| ...options.headers, | |||