diff --git a/src/api/chat.ts b/src/api/chat.ts index 25bb230a4..93c755663 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -1,7 +1,10 @@ import { GET, POST, DELETE } from './request'; import type { ChatItemType, HistoryItemType } from '@/types/chat'; -import type { InitChatResponse } from './response/chat'; +import type { InitChatResponse, InitShareChatResponse } from './response/chat'; import { RequestPaging } from '../types/index'; +import type { ShareChatSchema } from '@/types/mongoSchema'; +import type { ShareChatEditType } from '@/types/model'; +import { Obj2Query } from '@/utils/tools'; /** * 获取初始化聊天内容 @@ -35,3 +38,29 @@ export const postSaveChat = (data: { */ export const delChatRecordByIndex = (chatId: string, contentId: string) => DELETE(`/chat/delChatRecordByContentId?chatId=${chatId}&contentId=${contentId}`); + +/** + * create a shareChat + */ +export const createShareChat = ( + data: ShareChatEditType & { + modelId: string; + } +) => POST(`/chat/shareChat/create`, data); + +/** + * get shareChat + */ +export const getShareChatList = (modelId: string) => + GET(`/chat/shareChat/list?modelId=${modelId}`); + +/** + * delete a shareChat + */ +export const delShareChatById = (id: string) => DELETE(`/chat/shareChat/delete?id=${id}`); + +/** + * 初始化分享聊天 + */ +export const initShareChatInfo = (data: { shareId: string; password: string }) => + GET(`/chat/shareChat/init?${Obj2Query(data)}`); diff --git a/src/api/response/chat.d.ts b/src/api/response/chat.d.ts index c017a1a43..c7d8c344e 100644 --- a/src/api/response/chat.d.ts +++ b/src/api/response/chat.d.ts @@ -13,3 +13,13 @@ export interface InitChatResponse { chatModel: ModelSchema['chat']['chatModel']; // 对话模型名 history: ChatItemType[]; } + +export interface InitShareChatResponse { + maxContext: number; + model: { + name: string; + avatar: string; + intro: string; + }; + chatModel: ModelSchema['chat']['chatModel']; // 对话模型名 +} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 949947bb5..fea9ae15c 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -10,11 +10,13 @@ import NavbarPhone from './navbarPhone'; const pcUnShowLayoutRoute: Record = { '/': true, - '/login': true + '/login': true, + '/chat/share': true }; const phoneUnShowLayoutRoute: Record = { '/': true, - '/login': true + '/login': true, + '/chat/share': true }; const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: boolean }) => { @@ -67,7 +69,7 @@ const Layout = ({ children, isPcDevice }: { children: JSX.Element; isPcDevice: b )} - {loading && } + ); }; diff --git a/src/components/Loading/index.tsx b/src/components/Loading/index.tsx index 1352013fd..ff9489d18 100644 --- a/src/components/Loading/index.tsx +++ b/src/components/Loading/index.tsx @@ -5,7 +5,7 @@ const Loading = ({ fixed = true }: { fixed?: boolean }) => { return ( - {statusCode ? `An error ${statusCode} occurred on server` : 'An error occurred on client'} -

- ); +function Error({ errStr }: { errStr: string }) { + return

{errStr}

; } Error.getInitialProps = ({ res, err }: { res: any; err: any }) => { - const statusCode = res ? res.statusCode : err ? err.statusCode : 404; console.log(err); - return { statusCode }; + return { errStr: JSON.stringify(err) }; }; export default Error; diff --git a/src/pages/api/chat/shareChat/chat.ts b/src/pages/api/chat/shareChat/chat.ts new file mode 100644 index 000000000..3014c565c --- /dev/null +++ b/src/pages/api/chat/shareChat/chat.ts @@ -0,0 +1,130 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { connectToDatabase } from '@/service/mongo'; +import { authShareChat } from '@/service/utils/auth'; +import { modelServiceToolMap } from '@/service/utils/chat'; +import { ChatItemSimpleType } from '@/types/chat'; +import { jsonRes } from '@/service/response'; +import { PassThrough } from 'stream'; +import { ChatModelMap, ModelVectorSearchModeMap } from '@/constants/model'; +import { pushChatBill, updateShareChatBill } from '@/service/events/pushBill'; +import { resStreamResponse } from '@/service/utils/chat'; +import { searchKb } from '@/service/plugins/searchKb'; +import { ChatRoleEnum } from '@/constants/chat'; + +/* 发送提示词 */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + let step = 0; // step=1 时,表示开始了流响应 + const stream = new PassThrough(); + stream.on('error', () => { + console.log('error: ', 'stream error'); + stream.destroy(); + }); + res.on('close', () => { + stream.destroy(); + }); + res.on('error', () => { + console.log('error: ', 'request error'); + stream.destroy(); + }); + + try { + const { shareId, password, historyId, prompts } = req.body as { + prompts: ChatItemSimpleType[]; + password: string; + shareId: string; + historyId: string; + }; + + if (!historyId || !prompts) { + throw new Error('分享链接无效'); + } + + await connectToDatabase(); + let startTime = Date.now(); + + const { model, showModelDetail, userOpenAiKey, systemAuthKey, userId } = await authShareChat({ + shareId, + password + }); + + const modelConstantsData = ChatModelMap[model.chat.chatModel]; + + // 使用了知识库搜索 + if (model.chat.useKb) { + const { code, searchPrompts } = await searchKb({ + userOpenAiKey, + prompts, + similarity: ModelVectorSearchModeMap[model.chat.searchMode]?.similarity, + model, + userId + }); + + // search result is empty + if (code === 201) { + return res.send(searchPrompts[0]?.value); + } + + prompts.splice(prompts.length - 3, 0, ...searchPrompts); + } else { + // 没有用知识库搜索,仅用系统提示词 + model.chat.systemPrompt && + prompts.splice(prompts.length - 3, 0, { + obj: ChatRoleEnum.System, + value: model.chat.systemPrompt + }); + } + + // 计算温度 + const temperature = (modelConstantsData.maxTemperature * (model.chat.temperature / 10)).toFixed( + 2 + ); + + // 发出请求 + const { streamResponse } = await modelServiceToolMap[model.chat.chatModel].chatCompletion({ + apiKey: userOpenAiKey || systemAuthKey, + temperature: +temperature, + messages: prompts, + stream: true, + res, + chatId: historyId + }); + + console.log('api response time:', `${(Date.now() - startTime) / 1000}s`); + + step = 1; + + const { totalTokens, finishMessages } = await resStreamResponse({ + model: model.chat.chatModel, + res, + stream, + chatResponse: streamResponse, + prompts, + systemPrompt: '' + }); + + /* bill */ + pushChatBill({ + isPay: !userOpenAiKey, + chatModel: model.chat.chatModel, + userId, + textLen: finishMessages.map((item) => item.value).join('').length, + tokens: totalTokens + }); + updateShareChatBill({ + shareId, + tokens: totalTokens + }); + } catch (err: any) { + if (step === 1) { + // 直接结束流 + console.log('error,结束'); + stream.destroy(); + } else { + res.status(500); + jsonRes(res, { + code: 500, + error: err + }); + } + } +} diff --git a/src/pages/api/chat/shareChat/create.ts b/src/pages/api/chat/shareChat/create.ts new file mode 100644 index 000000000..4d4617d16 --- /dev/null +++ b/src/pages/api/chat/shareChat/create.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { connectToDatabase, ShareChat } from '@/service/mongo'; +import { authModel, authToken } from '@/service/utils/auth'; +import type { ShareChatEditType } from '@/types/model'; + +/* create a shareChat */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { modelId, name, maxContext, password } = req.body as ShareChatEditType & { + modelId: string; + }; + + await connectToDatabase(); + + const userId = await authToken(req); + await authModel({ + modelId, + userId + }); + + const { _id } = await ShareChat.create({ + userId, + modelId, + name, + password, + maxContext + }); + + jsonRes(res, { + data: _id + }); + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/src/pages/api/chat/shareChat/delete.ts b/src/pages/api/chat/shareChat/delete.ts new file mode 100644 index 000000000..736d7f761 --- /dev/null +++ b/src/pages/api/chat/shareChat/delete.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { connectToDatabase, ShareChat } from '@/service/mongo'; +import { authToken } from '@/service/utils/auth'; + +/* delete a shareChat by shareChatId */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { id } = req.query as { + id: string; + }; + + await connectToDatabase(); + + const userId = await authToken(req); + + await ShareChat.findOneAndRemove({ + _id: id, + userId + }); + + jsonRes(res); + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/src/pages/api/chat/shareChat/init.ts b/src/pages/api/chat/shareChat/init.ts new file mode 100644 index 000000000..548f65500 --- /dev/null +++ b/src/pages/api/chat/shareChat/init.ts @@ -0,0 +1,59 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { connectToDatabase, ShareChat } from '@/service/mongo'; +import type { InitShareChatResponse } from '@/api/response/chat'; +import { authModel } from '@/service/utils/auth'; +import { hashPassword } from '@/service/utils/tools'; + +/* 初始化我的聊天框,需要身份验证 */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + let { shareId, password = '' } = req.query as { + shareId: string; + password: string; + }; + + if (!shareId) { + throw new Error('params is error'); + } + + await connectToDatabase(); + + // get shareChat + const shareChat = await ShareChat.findById(shareId); + + if (!shareChat) { + throw new Error('分享链接已失效'); + } + + if (shareChat.password !== hashPassword(password)) { + return jsonRes(res, { + code: 501, + message: '密码不正确' + }); + } + + // 校验使用权限 + const { model } = await authModel({ + modelId: shareChat.modelId, + userId: String(shareChat.userId) + }); + + jsonRes(res, { + data: { + maxContext: shareChat.maxContext, + model: { + name: model.name, + avatar: model.avatar, + intro: model.share.intro + }, + chatModel: model.chat.chatModel + } + }); + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/src/pages/api/chat/shareChat/list.ts b/src/pages/api/chat/shareChat/list.ts new file mode 100644 index 000000000..2a3894e8b --- /dev/null +++ b/src/pages/api/chat/shareChat/list.ts @@ -0,0 +1,43 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/service/response'; +import { connectToDatabase, ShareChat } from '@/service/mongo'; +import { authToken } from '@/service/utils/auth'; +import { hashPassword } from '@/service/utils/tools'; + +/* get shareChat list by modelId */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const { modelId } = req.query as { + modelId: string; + }; + + await connectToDatabase(); + + const userId = await authToken(req); + + const data = await ShareChat.find({ + modelId, + userId + }).sort({ + _id: -1 + }); + + const blankPassword = hashPassword(''); + + jsonRes(res, { + data: data.map((item) => ({ + _id: item._id, + name: item.name, + password: item.password === blankPassword ? '' : '1', + tokens: item.tokens, + maxContext: item.maxContext, + lastTime: item.lastTime + })) + }); + } catch (err) { + jsonRes(res, { + code: 500, + error: err + }); + } +} diff --git a/src/pages/chat/components/Empty.tsx b/src/pages/chat/components/Empty.tsx index ccde333a5..9a686d3dc 100644 --- a/src/pages/chat/components/Empty.tsx +++ b/src/pages/chat/components/Empty.tsx @@ -26,21 +26,23 @@ const Empty = ({ alignItems={'center'} justifyContent={'center'} > - - - {''} - - {name} - - - {intro} - + {name && ( + + + {''} + + {name} + + + {intro} + + )} {/* version intro */} diff --git a/src/pages/chat/components/ShareHistory.tsx b/src/pages/chat/components/ShareHistory.tsx new file mode 100644 index 000000000..3c1c960bc --- /dev/null +++ b/src/pages/chat/components/ShareHistory.tsx @@ -0,0 +1,203 @@ +import React, { useCallback, useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; +import { AddIcon } from '@chakra-ui/icons'; +import { + Box, + Button, + Flex, + useTheme, + Menu, + MenuList, + MenuItem, + useOutsideClick +} from '@chakra-ui/react'; +import { ChatIcon } from '@chakra-ui/icons'; +import { useRouter } from 'next/router'; +import { formatTimeToChatTime } from '@/utils/tools'; +import MyIcon from '@/components/Icon'; +import type { ShareChatHistoryItemType, ExportChatType } from '@/types/chat'; +import { useChatStore } from '@/store/chat'; +import { useScreen } from '@/hooks/useScreen'; + +import styles from '../index.module.scss'; + +const PcSliderBar = ({ + isPcDevice, + onclickDelHistory, + onclickExportChat, + onCloseSlider +}: { + isPcDevice: boolean; + onclickDelHistory: (historyId: string) => void; + onclickExportChat: (type: ExportChatType) => void; + onCloseSlider: () => void; +}) => { + const router = useRouter(); + const { shareId = '', historyId = '' } = router.query as { shareId: string; historyId: string }; + const theme = useTheme(); + const { isPc } = useScreen({ defaultIsPc: isPcDevice }); + + const ContextMenuRef = useRef(null); + + const [contextMenuData, setContextMenuData] = useState<{ + left: number; + top: number; + history: ShareChatHistoryItemType; + }>(); + + const { shareChatHistory } = useChatStore(); + + // close contextMenu + useOutsideClick({ + ref: ContextMenuRef, + handler: () => + setTimeout(() => { + setContextMenuData(undefined); + }) + }); + + const onclickContextMenu = useCallback( + (e: MouseEvent, history: ShareChatHistoryItemType) => { + e.preventDefault(); // 阻止默认右键菜单 + + if (!isPc) return; + + setContextMenuData({ + left: e.clientX + 15, + top: e.clientY + 10, + history + }); + }, + [isPc] + ); + + const replaceChatPage = useCallback( + ({ hId = '', shareId }: { hId?: string; shareId: string }) => { + if (hId === historyId) return; + + router.replace(`/chat/share?shareId=${shareId}&historyId=${hId}`); + !isPc && onCloseSlider(); + }, + [historyId, isPc, onCloseSlider, router] + ); + + return ( + + {/* 新对话 */} + + + + + {/* chat history */} + + {shareChatHistory.map((item) => ( + replaceChatPage({ hId: item._id, shareId: item.shareId })} + onContextMenu={(e) => onclickContextMenu(e, item)} + > + + + + + {item.title} + + + {formatTimeToChatTime(item.updateTime)} + + + + {item.latestChat || '……'} + + + {/* phone quick delete */} + {!isPc && ( + { + e.stopPropagation(); + onclickDelHistory(item._id); + item._id === historyId && replaceChatPage({ shareId: item.shareId }); + }} + /> + )} + + ))} + {shareChatHistory.length === 0 && ( + + + + 还没有聊天记录 + + + )} + + {/* context menu */} + {contextMenuData && ( + + + + + { + onclickDelHistory(contextMenuData.history._id); + contextMenuData.history._id === historyId && replaceChatPage({ shareId }); + }} + > + 删除记录 + + onclickExportChat('html')}>导出HTML格式 + onclickExportChat('pdf')}>导出PDF格式 + onclickExportChat('md')}>导出Markdown格式 + + + + )} + + ); +}; + +export default PcSliderBar; diff --git a/src/pages/chat/share.tsx b/src/pages/chat/share.tsx new file mode 100644 index 000000000..0b30c6cca --- /dev/null +++ b/src/pages/chat/share.tsx @@ -0,0 +1,930 @@ +import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react'; +import { useRouter } from 'next/router'; +import { initShareChatInfo } from '@/api/chat'; +import type { ChatSiteItemType, ExportChatType } from '@/types/chat'; +import { + Textarea, + Box, + Flex, + useColorModeValue, + Menu, + MenuButton, + MenuList, + MenuItem, + Image, + Button, + Modal, + ModalOverlay, + ModalContent, + ModalBody, + ModalCloseButton, + useDisclosure, + Drawer, + DrawerOverlay, + DrawerContent, + Card, + Tooltip, + useOutsideClick, + useTheme, + Input, + ModalFooter, + ModalHeader +} from '@chakra-ui/react'; +import { useToast } from '@/hooks/useToast'; +import { useScreen } from '@/hooks/useScreen'; +import { useQuery } from '@tanstack/react-query'; +import dynamic from 'next/dynamic'; +import { useCopyData, voiceBroadcast } from '@/utils/tools'; +import { streamFetch } from '@/api/fetch'; +import MyIcon from '@/components/Icon'; +import { throttle } from 'lodash'; +import { Types } from 'mongoose'; +import Markdown from '@/components/Markdown'; +import { LOGO_ICON } from '@/constants/chat'; +import { useChatStore } from '@/store/chat'; +import { useLoading } from '@/hooks/useLoading'; +import { fileDownload } from '@/utils/file'; +import { htmlTemplate } from '@/constants/common'; +import { useUserStore } from '@/store/user'; +import Loading from '@/components/Loading'; + +const ShareHistory = dynamic(() => import('./components/ShareHistory'), { + loading: () => , + ssr: false +}); +const Empty = dynamic(() => import('./components/Empty'), { + loading: () => , + ssr: false +}); + +import styles from './index.module.scss'; + +const textareaMinH = '22px'; + +const Chat = ({ + shareId, + historyId, + isPcDevice +}: { + shareId: string; + historyId: string; + isPcDevice: boolean; +}) => { + const hasVoiceApi = !!window.speechSynthesis; + const router = useRouter(); + const theme = useTheme(); + + const ChatBox = useRef(null); + const TextareaDom = useRef(null); + const ContextMenuRef = useRef(null); + const PhoneContextShow = useRef(false); + + // 中断请求 + const controller = useRef(new AbortController()); + const isLeavePage = useRef(false); + + const [inputVal, setInputVal] = useState(''); // user input prompt + const [showSystemPrompt, setShowSystemPrompt] = useState(''); + const [messageContextMenuData, setMessageContextMenuData] = useState<{ + // message messageContextMenuData + left: number; + top: number; + message: ChatSiteItemType; + }>(); + const [foldSliderBar, setFoldSlideBar] = useState(false); + + const { + password, + setPassword, + shareChatHistory, + delShareHistoryById, + setShareChatHistory, + shareChatData, + setShareChatData, + delShareChatHistoryItemById, + delShareChatHistory + } = useChatStore(); + + const isChatting = useMemo( + () => shareChatData.history[shareChatData.history.length - 1]?.status === 'loading', + [shareChatData.history] + ); + + const { toast } = useToast(); + const { copyData } = useCopyData(); + const { isPc } = useScreen({ defaultIsPc: isPcDevice }); + const { Loading, setIsLoading } = useLoading(); + const { userInfo } = useUserStore(); + const { isOpen: isOpenSlider, onClose: onCloseSlider, onOpen: onOpenSlider } = useDisclosure(); + const { + isOpen: isOpenPassword, + onClose: onClosePassword, + onOpen: onOpenPassword + } = useDisclosure(); + + // close contextMenu + useOutsideClick({ + ref: ContextMenuRef, + handler: () => { + // 移动端长按后会将其设置为true,松手时候也会触发一次,松手的时候需要忽略一次。 + if (PhoneContextShow.current) { + PhoneContextShow.current = false; + } else { + messageContextMenuData && + setTimeout(() => { + setMessageContextMenuData(undefined); + window.getSelection?.()?.empty?.(); + window.getSelection?.()?.removeAllRanges?.(); + document?.getSelection()?.empty(); + }); + } + } + }); + + // 滚动到底部 + const scrollToBottom = useCallback((behavior: 'smooth' | 'auto' = 'smooth') => { + if (!ChatBox.current) return; + ChatBox.current.scrollTo({ + top: ChatBox.current.scrollHeight, + behavior + }); + }, []); + + // 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部 + // eslint-disable-next-line react-hooks/exhaustive-deps + const generatingMessage = useCallback( + throttle(() => { + if (!ChatBox.current) return; + const isBottom = + ChatBox.current.scrollTop + ChatBox.current.clientHeight + 150 >= + ChatBox.current.scrollHeight; + + isBottom && scrollToBottom('auto'); + }, 100), + [] + ); + + // 重置输入内容 + const resetInputVal = useCallback((val: string) => { + setInputVal(val); + setTimeout(() => { + /* 回到最小高度 */ + if (TextareaDom.current) { + TextareaDom.current.style.height = + val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`; + } + }, 100); + }, []); + + // gpt 对话 + const gptChatPrompt = useCallback( + async (prompts: ChatSiteItemType[]) => { + // create abort obj + const abortSignal = new AbortController(); + controller.current = abortSignal; + isLeavePage.current = false; + + const formatPrompts = prompts.map((item) => ({ + obj: item.obj, + value: item.value + })); + + // 流请求,获取数据 + const { responseText, systemPrompt } = await streamFetch({ + url: '/api/chat/shareChat/chat', + data: { + prompts: formatPrompts.slice(-shareChatData.maxContext - 1, -1), + password, + shareId, + historyId + }, + onMessage: (text: string) => { + setShareChatData((state) => ({ + ...state, + history: state.history.map((item, index) => { + if (index !== state.history.length - 1) return item; + return { + ...item, + value: item.value + text + }; + }) + })); + generatingMessage(); + }, + abortSignal + }); + + // 重置了页面,说明退出了当前聊天, 不缓存任何内容 + if (isLeavePage.current) { + return; + } + + let responseHistory: ChatSiteItemType[] = []; + + // 设置聊天内容为完成状态 + setShareChatData((state) => { + responseHistory = state.history.map((item, index) => { + if (index !== state.history.length - 1) return item; + return { + ...item, + status: 'finish', + systemPrompt + }; + }); + + return { + ...state, + history: responseHistory + }; + }); + + setShareChatHistory({ + historyId, + shareId, + title: formatPrompts[formatPrompts.length - 2].value, + latestChat: responseText, + chats: responseHistory + }); + + setTimeout(() => { + generatingMessage(); + }, 100); + }, + [ + generatingMessage, + historyId, + password, + setShareChatData, + setShareChatHistory, + shareChatData.maxContext, + shareId + ] + ); + + /** + * 发送一个内容 + */ + const sendPrompt = useCallback(async () => { + if (isChatting) { + toast({ + title: '正在聊天中...请等待结束', + status: 'warning' + }); + return; + } + const storeInput = inputVal; + // 去除空行 + const val = inputVal.trim().replace(/\n\s*/g, '\n'); + + if (!val) { + toast({ + title: '内容为空', + status: 'warning' + }); + return; + } + + const newChatList: ChatSiteItemType[] = [ + ...shareChatData.history, + { + _id: String(new Types.ObjectId()), + obj: 'Human', + value: val, + status: 'finish' + }, + { + _id: String(new Types.ObjectId()), + obj: 'AI', + value: '', + status: 'loading' + } + ]; + + // 插入内容 + setShareChatData((state) => ({ + ...state, + history: newChatList + })); + + // 清空输入内容 + resetInputVal(''); + setTimeout(() => { + scrollToBottom(); + }, 100); + + try { + await gptChatPrompt(newChatList); + } catch (err: any) { + toast({ + title: typeof err === 'string' ? err : err?.message || '聊天出错了~', + status: 'warning', + duration: 5000, + isClosable: true + }); + + resetInputVal(storeInput); + + setShareChatData((state) => ({ + ...state, + history: newChatList.slice(0, newChatList.length - 2) + })); + } + }, [ + isChatting, + inputVal, + shareChatData.history, + setShareChatData, + resetInputVal, + toast, + scrollToBottom, + gptChatPrompt + ]); + + // 复制内容 + const onclickCopy = useCallback( + (value: string) => { + const val = value.replace(/\n+/g, '\n'); + copyData(val); + }, + [copyData] + ); + + // export chat data + const onclickExportChat = useCallback( + (type: ExportChatType) => { + const getHistoryHtml = () => { + const historyDom = document.getElementById('history'); + if (!historyDom) return; + const dom = Array.from(historyDom.children).map((child, i) => { + const avatar = ``; + + const chatContent = child.querySelector('.markdown'); + + if (!chatContent) { + return ''; + } + + const chatContentClone = chatContent.cloneNode(true) as HTMLDivElement; + + const codeHeader = chatContentClone.querySelectorAll('.code-header'); + codeHeader.forEach((childElement: any) => { + childElement.remove(); + }); + + return `
+ ${avatar} + ${chatContentClone.outerHTML} +
`; + }); + + const html = htmlTemplate.replace('{{CHAT_CONTENT}}', dom.join('\n')); + return html; + }; + + const map: Record void> = { + md: () => { + fileDownload({ + text: shareChatData.history.map((item) => item.value).join('\n\n'), + type: 'text/markdown', + filename: 'chat.md' + }); + }, + html: () => { + const html = getHistoryHtml(); + html && + fileDownload({ + text: html, + type: 'text/html', + filename: '聊天记录.html' + }); + }, + pdf: () => { + const html = getHistoryHtml(); + + html && + // @ts-ignore + html2pdf(html, { + margin: 0, + filename: `聊天记录.pdf` + }); + } + }; + + map[type](); + }, + [shareChatData.history] + ); + + // onclick chat message context + const onclickContextMenu = useCallback( + (e: MouseEvent, message: ChatSiteItemType) => { + e.preventDefault(); // 阻止默认右键菜单 + + // select all text + const range = document.createRange(); + range.selectNodeContents(e.currentTarget as HTMLDivElement); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + + navigator.vibrate?.(50); // 震动 50 毫秒 + + if (!isPcDevice) { + PhoneContextShow.current = true; + } + + setMessageContextMenuData({ + left: e.clientX - 20, + top: e.clientY, + message + }); + + return false; + }, + [isPcDevice] + ); + + // 获取对话信息 + const loadChatInfo = useCallback(async () => { + setIsLoading(true); + try { + const res = await initShareChatInfo({ + shareId, + password + }); + + setShareChatData({ + ...res, + history: shareChatHistory.find((item) => item._id === historyId)?.chats || [] + }); + + onClosePassword(); + + setTimeout(() => { + scrollToBottom(); + }, 500); + } catch (e: any) { + toast({ + status: 'error', + title: typeof e === 'string' ? e : e?.message || '初始化异常' + }); + if (e?.code === 501) { + onOpenPassword(); + } else { + delShareChatHistory(shareId); + router.replace(`/chat/share`); + } + } + setIsLoading(false); + return null; + }, [ + setIsLoading, + shareId, + password, + setShareChatData, + shareChatHistory, + onClosePassword, + historyId, + scrollToBottom, + toast, + onOpenPassword, + delShareChatHistory, + router + ]); + + // 初始化聊天框 + useQuery(['init', historyId], () => { + if (!shareId) { + return null; + } + + if (!historyId) { + router.replace(`/chat/share?shareId=${shareId}&historyId=${new Types.ObjectId()}`); + return null; + } + + return loadChatInfo(); + }); + + // abort stream + useEffect(() => { + return () => { + window.speechSynthesis?.cancel(); + isLeavePage.current = true; + controller.current?.abort(); + }; + }, [shareId, historyId]); + + // context menu component + const RenderContextMenu = useCallback( + ({ history, index }: { history: ChatSiteItemType; index: number }) => ( + + onclickCopy(history.value)}>复制 + {hasVoiceApi && ( + voiceBroadcast({ text: history.value })} + > + 语音播报 + + )} + + delShareChatHistoryItemById(historyId, index)}>删除 + + ), + [delShareChatHistoryItemById, hasVoiceApi, historyId, onclickCopy, theme.borders.base] + ); + + return ( + + {/* pc always show history. */} + {isPc && ( + div': { visibility: 'visible', opacity: 1 } + }} + > + setFoldSlideBar(!foldSliderBar)} + > + + + + + + + )} + + {/* 聊天内容 */} + + {/* chat header */} + + {!isPc && ( + + )} + + {shareChatData.model.name} + {shareChatData.history.length > 0 ? ` (${shareChatData.history.length})` : ''} + + {shareChatData.history.length > 0 ? ( + + + + + + router.replace(`/chat/share?shareId=${shareId}`)}> + 新对话 + + { + delShareHistoryById(historyId); + router.replace(`/chat/share?shareId=${shareId}`); + }} + > + 删除记录 + + onclickExportChat('html')}>导出HTML格式 + onclickExportChat('pdf')}>导出PDF格式 + onclickExportChat('md')}>导出Markdown格式 + + + ) : ( + + )} + + {/* chat content box */} + + + {shareChatData.history.map((item, index) => ( + + {item.obj === 'Human' && } + {/* avatar */} + + + + avatar + + + {!isPc && } + + {/* message */} + + {item.obj === 'AI' ? ( + + onclickContextMenu(e, item)} + > + + {item.systemPrompt && ( + + )} + + + ) : ( + + onclickContextMenu(e, item)} + > + {item.value} + + + )} + + + ))} + {shareChatData.history.length === 0 && } + + + {/* 发送区 */} + + + {/* 输入框 */} +