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, 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 { useGlobalStore } from '@/store/global'; import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; import { useCopyData, voiceBroadcast, hasVoiceApi } from '@/utils/tools'; import { streamFetch } from '@/api/fetch'; import MyIcon from '@/components/Icon'; import { throttle } from 'lodash'; import { Types } from 'mongoose'; 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'; import Markdown from '@/components/Markdown'; import SideBar from '@/components/SideBar'; import Avatar from '@/components/Avatar'; import Empty from './components/Empty'; const ShareHistory = dynamic(() => import('./components/ShareHistory'), { loading: () => , ssr: false }); import styles from './index.module.scss'; const textareaMinH = '22px'; const Chat = ({ shareId, historyId }: { shareId: string; historyId: string }) => { 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 [messageContextMenuData, setMessageContextMenuData] = useState<{ // message messageContextMenuData left: number; top: number; message: ChatSiteItemType; }>(); 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 } = useGlobalStore(); 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 } = 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' }; }); 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 (!isPc) { PhoneContextShow.current = true; } setMessageContextMenuData({ left: e.clientX - 20, top: e.clientY, message }); return false; }, [isPc] ); // 获取对话信息 const loadChatInfo = useCallback(async () => { setIsLoading(true); try { const res = await initShareChatInfo({ shareId, password }); const history = shareChatHistory.find((item) => item._id === historyId)?.chats || []; setShareChatData({ ...res, history }); onClosePassword(); history.length > 0 && 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, historyId, onclickCopy, theme.borders.base] ); return ( {/* pc always show history. */} {isPc && ( )} {/* 聊天内容 */} {/* 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 */} {!isPc && } {/* message */} {item.obj === 'AI' ? ( onclickContextMenu(e, item)} > ) : ( onclickContextMenu(e, item)} > {item.value} )} ))} {shareChatData.history.length === 0 && ( )} {/* 发送区 */} {/* 输入框 */}