import React, { useCallback, useState, useRef, useMemo, useEffect, MouseEvent } from 'react'; import { useRouter } from 'next/router'; import { getInitChatSiteInfo, delChatRecordByIndex, delChatHistoryById } from '@/api/chat'; import type { ChatItemType, ChatSiteItemType, ExportChatType } from '@/types/chat'; import { Textarea, Box, Flex, useColorModeValue, Menu, MenuButton, MenuList, MenuItem, Button, Modal, ModalOverlay, ModalContent, ModalBody, ModalCloseButton, ModalHeader, useDisclosure, Drawer, DrawerOverlay, DrawerContent, Card, Tooltip, useOutsideClick, useTheme } 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, delay } 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 { ChatModelMap } from '@/constants/model'; 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'; import QuoteModal from './components/QuoteModal'; const PhoneSliderBar = dynamic(() => import('./components/PhoneSliderBar'), { ssr: false }); const History = dynamic(() => import('./components/History'), { loading: () => , ssr: false }); import styles from './index.module.scss'; const textareaMinH = '22px'; const Chat = ({ modelId, chatId }: { modelId: string; chatId: 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 [showHistoryQuote, setShowHistoryQuote] = useState(); const [showSystemPrompt, setShowSystemPrompt] = useState(''); const [messageContextMenuData, setMessageContextMenuData] = useState<{ // message messageContextMenuData left: number; top: number; message: ChatSiteItemType; }>(); const { lastChatModelId, setLastChatModelId, lastChatId, setLastChatId, loadHistory, chatData, setChatData, forbidLoadChatData, setForbidLoadChatData } = useChatStore(); const isChatting = useMemo( () => chatData.history[chatData.history.length - 1]?.status === 'loading', [chatData.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(); // 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) => { if (!TextareaDom.current) return; TextareaDom.current.value = 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 prompt: ChatItemType[] = prompts.map((item) => ({ _id: item._id, obj: item.obj, value: item.value })); // 流请求,获取数据 const { newChatId, quoteLen, systemPrompt } = await streamFetch({ url: '/api/chat/chat', data: { prompt, chatId, modelId }, onMessage: (text: string) => { setChatData((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; } if (newChatId) { setForbidLoadChatData(true); router.replace(`/chat?modelId=${modelId}&chatId=${newChatId}`); } abortSignal.signal.aborted && (await delay(600)); // 设置聊天内容为完成状态 setChatData((state) => ({ ...state, chatId: newChatId || state.chatId, // 如果有 Id,说明是新创建的对话 history: state.history.map((item, index) => { if (index !== state.history.length - 1) return item; return { ...item, status: 'finish', quoteLen, systemPrompt }; }) })); // refresh history setTimeout(() => { loadHistory({ pageNum: 1, init: true }); generatingMessage(); }, 100); }, [chatId, setForbidLoadChatData, generatingMessage, loadHistory, modelId, router, setChatData] ); /** * 发送一个内容 */ const sendPrompt = useCallback(async () => { // get value if (isChatting) { toast({ title: '正在聊天中...请等待结束', status: 'warning' }); return; } // get input value const value = TextareaDom.current?.value || ''; const val = value.trim().replace(/\n\s*/g, '\n'); if (!val) { toast({ title: '内容为空', status: 'warning' }); return; } const newChatList: ChatSiteItemType[] = [ ...chatData.history, { _id: String(new Types.ObjectId()), obj: 'Human', value: val, status: 'finish' }, { _id: String(new Types.ObjectId()), obj: 'AI', value: '', status: 'loading' } ]; // 插入内容 setChatData((state) => ({ ...state, history: newChatList })); // 清空输入内容 resetInputVal(''); setTimeout(() => { scrollToBottom(); }, 100); try { await gptChatPrompt(newChatList.slice(newChatList.length - 2)); } catch (err: any) { toast({ title: typeof err === 'string' ? err : err?.message || '聊天出错了~', status: 'warning', duration: 5000, isClosable: true }); resetInputVal(value); setChatData((state) => ({ ...state, history: newChatList.slice(0, newChatList.length - 2) })); } }, [ isChatting, chatData.history, setChatData, resetInputVal, toast, scrollToBottom, gptChatPrompt ]); // 删除一句话 const delChatRecord = useCallback( async (index: number, historyId: string) => { if (!messageContextMenuData) return; setIsLoading(true); try { // 删除数据库最后一句 await delChatRecordByIndex(chatId, historyId); setChatData((state) => ({ ...state, history: state.history.filter((_, i) => i !== index) })); } catch (err) { console.log(err); } setIsLoading(false); }, [chatId, messageContextMenuData, setChatData, setIsLoading] ); // 复制内容 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: chatData.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](); }, [chatData.history] ); // delete history and reload history const onclickDelHistory = useCallback( async (historyId: string) => { await delChatHistoryById(historyId); loadHistory({ pageNum: 1, init: true }); }, [loadHistory] ); // 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 ({ modelId, chatId, isLoading = false }: { modelId: string; chatId: string; isLoading?: boolean; }) => { isLoading && setIsLoading(true); try { const res = await getInitChatSiteInfo(modelId, chatId); setChatData({ ...res, history: res.history.map((item) => ({ ...item, status: 'finish' })) }); // have records. if (res.history.length > 0) { setTimeout(() => { scrollToBottom('auto'); }, 300); } // 空 modelId 请求, 重定向到新的 model 聊天 if (res.modelId !== modelId) { setForbidLoadChatData(true); router.replace(`/chat?modelId=${res.modelId}`); } } catch (e: any) { // reset all chat tore setLastChatModelId(''); setLastChatId(''); setChatData(); loadHistory({ pageNum: 1, init: true }); router.replace('/chat'); } setIsLoading(false); return null; }, [ router, loadHistory, setForbidLoadChatData, scrollToBottom, setChatData, setIsLoading, setLastChatId, setLastChatModelId ] ); // 初始化聊天框 const { isLoading } = useQuery(['init', modelId, chatId], () => { // pc: redirect to latest model chat if (!modelId && lastChatModelId) { router.replace(`/chat?modelId=${lastChatModelId}&chatId=${lastChatId}`); return null; } // store id modelId && setLastChatModelId(modelId); setLastChatId(chatId); if (forbidLoadChatData) { setForbidLoadChatData(false); return null; } return loadChatInfo({ modelId, chatId }); }); // abort stream useEffect(() => { return () => { window.speechSynthesis?.cancel(); isLeavePage.current = true; controller.current?.abort(); }; }, [modelId, chatId]); // context menu component const RenderContextMenu = useCallback( ({ history, index, AiDetail = false }: { history: ChatSiteItemType; index: number; AiDetail?: boolean; }) => ( onclickCopy(history.value)}>复制 {AiDetail && chatData.model.canUse && history.obj === 'AI' && ( router.push(`/model?modelId=${chatData.modelId}`)} > 应用详情 )} {hasVoiceApi && ( voiceBroadcast({ text: history.value })} > 语音播报 )} delChatRecord(index, history._id)}>删除 ), [ chatData.model.canUse, chatData.modelId, delChatRecord, onclickCopy, router, theme.borders.base ] ); return ( {/* pc always show history. */} {(isPc || !modelId) && ( )} {/* 聊天内容 */} {modelId && ( {/* chat header */} {!isPc && ( )} router.push(`/model?modelId=${chatData.modelId}`)} > {chatData.model.name} {ChatModelMap[chatData.chatModel].name} {chatData.history.length > 0 ? ` (${chatData.history.length})` : ''} {chatId ? ( router.replace(`/chat?modelId=${modelId}`)}> 新对话 { try { setIsLoading(true); await onclickDelHistory(chatData.chatId); router.replace(`/chat?modelId=${modelId}`); } catch (err) { console.log(err); } setIsLoading(false); }} > 删除记录 onclickExportChat('html')}>导出HTML格式 onclickExportChat('pdf')}>导出PDF格式 onclickExportChat('md')}>导出Markdown格式 ) : ( )} {/* chat content box */} {chatData.history.map((item, index) => ( {item.obj === 'Human' && } {/* avatar */} isPc && chatData.model.canUse && router.push(`/model?modelId=${chatData.modelId}`) } : { order: 3, ml: ['6px', 2] })} > {!isPc && } {/* message */} {item.obj === 'AI' ? ( onclickContextMenu(e, item)} > {!!item.systemPrompt && ( )} {!!item.quoteLen && ( )} ) : ( onclickContextMenu(e, item)} > {item.value} )} ))} {chatData.history.length === 0 && ( )} {/* 发送区 */} {chatData.model.canUse ? ( {/* 输入框 */}