import React, { useCallback, useRef, useState, useMemo, forwardRef, useImperativeHandle, ForwardedRef, useEffect } from 'react'; import Script from 'next/script'; import { throttle } from 'lodash'; import type { ExportChatType } from '@/types/chat.d'; import type { ChatItemType, ChatSiteItemType } from '@fastgpt/global/core/chat/type.d'; import type { ChatHistoryItemResType } from '@fastgpt/global/core/chat/type.d'; import { useToast } from '@/web/common/hooks/useToast'; import { useAudioPlay } from '@/web/common/utils/voice'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { useCopyData } from '@/web/common/hooks/useCopyData'; import { Box, Card, Flex, Input, Button, useTheme, BoxProps, FlexProps, Image, Textarea, Checkbox } from '@chakra-ui/react'; import { feConfigs } from '@/web/common/system/staticData'; import { EventNameEnum, eventBus } from '@/web/common/utils/eventbus'; import { adaptChat2GptMessages } from '@fastgpt/global/core/chat/adapt'; import { useMarkdown } from '@/web/common/hooks/useMarkdown'; import { ModuleItemType } from '@fastgpt/global/core/module/type.d'; import { VariableInputEnum } from '@fastgpt/global/core/module/constants'; import { useForm } from 'react-hook-form'; import type { ChatMessageItemType } from '@fastgpt/global/core/ai/type.d'; import { fileDownload } from '@/web/common/file/utils'; import { htmlTemplate } from '@/constants/common'; import { useRouter } from 'next/router'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useTranslation } from 'next-i18next'; import { customAlphabet } from 'nanoid'; import { closeCustomFeedback, updateChatAdminFeedback, updateChatUserFeedback } from '@/web/core/chat/api'; import type { AdminMarkType } from './SelectMarkCollection'; import MyIcon from '@/components/Icon'; import Avatar from '@/components/Avatar'; import Markdown, { CodeClassName } from '@/components/Markdown'; import MySelect from '@/components/Select'; import MyTooltip from '../MyTooltip'; import dynamic from 'next/dynamic'; const ResponseTags = dynamic(() => import('./ResponseTags')); const FeedbackModal = dynamic(() => import('./FeedbackModal')); const ReadFeedbackModal = dynamic(() => import('./ReadFeedbackModal')); const SelectMarkCollection = dynamic(() => import('./SelectMarkCollection')); import styles from './index.module.scss'; import { postQuestionGuide } from '@/web/core/ai/api'; import { splitGuideModule } from '@fastgpt/global/core/module/utils'; import type { AppTTSConfigType } from '@fastgpt/global/core/module/type.d'; import MessageInput from './MessageInput'; import { ModuleOutputKeyEnum } from '@fastgpt/global/core/module/constants'; import ChatBoxDivider from '../core/chat/Divider'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 24); const textareaMinH = '22px'; type generatingMessageProps = { text?: string; name?: string; status?: 'running' | 'finish' }; export type StartChatFnProps = { chatList: ChatSiteItemType[]; messages: ChatMessageItemType[]; controller: AbortController; variables: Record; generatingMessage: (e: generatingMessageProps) => void; }; export type ComponentRef = { getChatHistories: () => ChatSiteItemType[]; resetVariables: (data?: Record) => void; resetHistory: (history: ChatSiteItemType[]) => void; scrollToBottom: (behavior?: 'smooth' | 'auto') => void; sendPrompt: (question: string) => void; }; enum FeedbackTypeEnum { user = 'user', admin = 'admin', hidden = 'hidden' } type Props = { feedbackType?: `${FeedbackTypeEnum}`; showMarkIcon?: boolean; // admin mark dataset showVoiceIcon?: boolean; showEmptyIntro?: boolean; appAvatar?: string; userAvatar?: string; userGuideModule?: ModuleItemType; showFileSelector?: boolean; active?: boolean; // can use // not chat test params appId?: string; chatId?: string; shareId?: string; outLinkUid?: string; onUpdateVariable?: (e: Record) => void; onStartChat?: (e: StartChatFnProps) => Promise<{ responseText: string; [ModuleOutputKeyEnum.responseData]: ChatHistoryItemResType[]; isNewChat?: boolean; }>; onDelMessage?: (e: { contentId?: string; index: number }) => void; }; const ChatBox = ( { feedbackType = FeedbackTypeEnum.hidden, showMarkIcon = false, showVoiceIcon = true, showEmptyIntro = false, appAvatar, userAvatar, userGuideModule, showFileSelector, active = true, appId, chatId, shareId, outLinkUid, onUpdateVariable, onStartChat, onDelMessage }: Props, ref: ForwardedRef ) => { const ChatBoxRef = useRef(null); const theme = useTheme(); const router = useRouter(); const { t } = useTranslation(); const { toast } = useToast(); const { isPc, setLoading } = useSystemStore(); const TextareaDom = useRef(null); const chatController = useRef(new AbortController()); const questionGuideController = useRef(new AbortController()); const isNewChatReplace = useRef(false); const [refresh, setRefresh] = useState(false); const [variables, setVariables] = useState>({}); // settings variable const [chatHistory, setChatHistory] = useState([]); const [feedbackId, setFeedbackId] = useState(); const [readFeedbackData, setReadFeedbackData] = useState<{ chatItemId: string; content: string; }>(); const [adminMarkData, setAdminMarkData] = useState(); const [questionGuides, setQuestionGuide] = useState([]); const isChatting = useMemo( () => chatHistory[chatHistory.length - 1] && chatHistory[chatHistory.length - 1]?.status !== 'finish', [chatHistory] ); const { welcomeText, variableModules, questionGuide, ttsConfig } = useMemo( () => splitGuideModule(userGuideModule), [userGuideModule] ); // compute variable input is finish. const [variableInputFinish, setVariableInputFinish] = useState(false); const variableIsFinish = useMemo(() => { if (!variableModules || variableModules.length === 0 || chatHistory.length > 0) return true; for (let i = 0; i < variableModules.length; i++) { const item = variableModules[i]; if (item.required && !variables[item.key]) { return false; } } return variableInputFinish; }, [chatHistory.length, variableInputFinish, variableModules, variables]); const { register, reset, getValues, setValue, handleSubmit } = useForm>({ defaultValues: variables }); // 滚动到底部 const scrollToBottom = useCallback( (behavior: 'smooth' | 'auto' = 'smooth') => { if (!ChatBoxRef.current) return; ChatBoxRef.current.scrollTo({ top: ChatBoxRef.current.scrollHeight, behavior }); }, [ChatBoxRef] ); // 聊天信息生成中……获取当前滚动条位置,判断是否需要滚动到底部 const generatingScroll = useCallback( throttle(() => { if (!ChatBoxRef.current) return; const isBottom = ChatBoxRef.current.scrollTop + ChatBoxRef.current.clientHeight + 150 >= ChatBoxRef.current.scrollHeight; isBottom && scrollToBottom('auto'); }, 100), [] ); // eslint-disable-next-line react-hooks/exhaustive-deps const generatingMessage = ({ text = '', status, name }: generatingMessageProps) => { setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, ...(text ? { value: item.value + text } : {}), ...(status && name ? { status, moduleName: name } : {}) }; }) ); generatingScroll(); }; // 重置输入内容 const resetInputVal = useCallback((val: string) => { if (!TextareaDom.current) return; setTimeout(() => { /* 回到最小高度 */ if (TextareaDom.current) { TextareaDom.current.value = val; TextareaDom.current.style.height = val === '' ? textareaMinH : `${TextareaDom.current.scrollHeight}px`; } setRefresh((state) => !state); }, 100); }, []); // create question guide const createQuestionGuide = useCallback( async ({ history }: { history: ChatSiteItemType[] }) => { if (!questionGuide || chatController.current?.signal?.aborted) return; try { const abortSignal = new AbortController(); questionGuideController.current = abortSignal; const result = await postQuestionGuide( { messages: adaptChat2GptMessages({ messages: history, reserveId: false }).slice(-6), shareId }, abortSignal ); if (Array.isArray(result)) { setQuestionGuide(result); setTimeout(() => { scrollToBottom(); }, 100); } } catch (error) {} }, [questionGuide, scrollToBottom, shareId] ); /** * user confirm send prompt */ const sendPrompt = useCallback( async (variables: Record = {}, inputVal = '', history = chatHistory) => { if (!onStartChat) return; if (isChatting) { toast({ title: '正在聊天中...请等待结束', status: 'warning' }); return; } questionGuideController.current?.abort('stop'); // get input value const val = inputVal.trim(); if (!val) { toast({ title: '内容为空', status: 'warning' }); return; } const newChatList: ChatSiteItemType[] = [ ...history, { dataId: nanoid(), obj: 'Human', value: val, status: 'finish' }, { dataId: nanoid(), obj: 'AI', value: '', status: 'loading' } ]; // 插入内容 setChatHistory(newChatList); // 清空输入内容 resetInputVal(''); setQuestionGuide([]); setTimeout(() => { scrollToBottom(); }, 100); try { // create abort obj const abortSignal = new AbortController(); chatController.current = abortSignal; const messages = adaptChat2GptMessages({ messages: newChatList, reserveId: true }); const { responseData, responseText, isNewChat = false } = await onStartChat({ chatList: newChatList.map((item) => ({ dataId: item.dataId, obj: item.obj, value: item.value, status: item.status, moduleName: item.moduleName })), messages, controller: abortSignal, generatingMessage, variables }); isNewChatReplace.current = isNewChat; // set finish status setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, status: 'finish', responseData }; }) ); setTimeout(() => { createQuestionGuide({ history: newChatList.map((item, i) => i === newChatList.length - 1 ? { ...item, value: responseText } : item ) }); generatingScroll(); isPc && TextareaDom.current?.focus(); }, 100); } catch (err: any) { toast({ title: t(getErrText(err, 'core.chat.error.Chat error')), status: 'error', duration: 5000, isClosable: true }); if (!err?.responseText) { resetInputVal(inputVal); setChatHistory(newChatList.slice(0, newChatList.length - 2)); } // set finish status setChatHistory((state) => state.map((item, index) => { if (index !== state.length - 1) return item; return { ...item, status: 'finish' }; }) ); } }, [ chatHistory, onStartChat, isChatting, resetInputVal, toast, scrollToBottom, generatingMessage, createQuestionGuide, generatingScroll, isPc, t ] ); // retry input const retryInput = useCallback( async (index: number) => { if (!onDelMessage) return; const delHistory = chatHistory.slice(index); setLoading(true); try { await Promise.all( delHistory.map((item, i) => onDelMessage({ contentId: item.dataId, index: index + i })) ); setChatHistory((state) => (index === 0 ? [] : state.slice(0, index))); sendPrompt(variables, delHistory[0].value, chatHistory.slice(0, index)); } catch (error) {} setLoading(false); }, [chatHistory, onDelMessage, sendPrompt, setLoading, variables] ); // delete one message const delOneMessage = useCallback( ({ dataId, index }: { dataId?: string; index: number }) => { setChatHistory((state) => state.filter((chat) => chat.dataId !== dataId)); onDelMessage?.({ contentId: dataId, index }); }, [onDelMessage] ); // output data useImperativeHandle(ref, () => ({ getChatHistories: () => chatHistory, resetVariables(e) { const defaultVal: Record = {}; variableModules?.forEach((item) => { defaultVal[item.key] = ''; }); reset(e || defaultVal); setVariables(e || defaultVal); }, resetHistory(e) { setVariableInputFinish(!!e.length); setChatHistory(e); }, scrollToBottom, sendPrompt: (question: string) => handleSubmit((item) => sendPrompt(item, question))() })); /* style start */ const MessageCardStyle: BoxProps = { px: 4, py: 3, borderRadius: '0 8px 8px 8px', boxShadow: '0 0 8px rgba(0,0,0,0.15)', display: 'inline-block', maxW: ['calc(100% - 25px)', 'calc(100% - 40px)'] }; const showEmpty = useMemo( () => feConfigs?.show_emptyChat && showEmptyIntro && chatHistory.length === 0 && !variableModules?.length && !welcomeText, [chatHistory.length, showEmptyIntro, variableModules, welcomeText] ); const statusBoxData = useMemo(() => { const colorMap = { loading: 'myGray.700', running: '#67c13b', finish: 'primary.500' }; if (!isChatting) return; const chatContent = chatHistory[chatHistory.length - 1]; if (!chatContent) return; return { bg: colorMap[chatContent.status] || colorMap.loading, name: t(chatContent.moduleName || '') || t('common.Loading') }; }, [chatHistory, isChatting, t]); /* style end */ // page change and abort request useEffect(() => { isNewChatReplace.current = false; setQuestionGuide([]); return () => { chatController.current?.abort('leave'); if (!isNewChatReplace.current) { questionGuideController.current?.abort('leave'); } }; }, [router.query]); // add listener useEffect(() => { const windowMessage = ({ data }: MessageEvent<{ type: 'sendPrompt'; text: string }>) => { if (data?.type === 'sendPrompt' && data?.text) { handleSubmit((item) => sendPrompt(item, data.text))(); } }; window.addEventListener('message', windowMessage); eventBus.on(EventNameEnum.sendQuestion, ({ text }: { text: string }) => { if (!text) return; handleSubmit((data) => sendPrompt(data, text))(); }); eventBus.on(EventNameEnum.editQuestion, ({ text }: { text: string }) => { if (!text) return; resetInputVal(text); }); return () => { window.removeEventListener('message', windowMessage); eventBus.off(EventNameEnum.sendQuestion); eventBus.off(EventNameEnum.editQuestion); }; }, [handleSubmit, resetInputVal, sendPrompt]); return ( {/* chat box container */} {showEmpty && } {!!welcomeText && ( {/* avatar */} {/* message */} )} {/* variable input */} {!!variableModules?.length && ( {/* avatar */} {/* message */} {variableModules.map((item) => ( {item.label} {item.type === VariableInputEnum.input && ( )} {item.type === VariableInputEnum.textarea && (