### 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)
Before Width: | Height: | Size: 545 KiB After Width: | Height: | Size: 406 KiB |
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 388 KiB |
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 467 KiB |
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 311 KiB After Width: | Height: | Size: 966 KiB |
Before Width: | Height: | Size: 599 KiB After Width: | Height: | Size: 515 KiB |
Before Width: | Height: | Size: 872 KiB After Width: | Height: | Size: 196 KiB |
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 296 KiB |
@ -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]);
|
||||
|
||||
|
25
web/src/pages/chat/markdown-content/index.less
Normal file
@ -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;
|
||||
}
|
173
web/src/pages/chat/markdown-content/index.tsx
Normal file
@ -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(
|
||||
|
50
web/src/pages/chat/share/index.less
Normal file
@ -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;
|
||||
}
|
53
web/src/pages/chat/share/index.tsx
Normal file
@ -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;
|
122
web/src/pages/chat/share/large.tsx
Normal file
@ -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);
|
32
web/src/pages/chat/share/shared-markdown.tsx
Normal file
@ -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;
|
192
web/src/pages/chat/shared-hooks.ts
Normal file
@ -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);
|
||||
|
||||
|