import { IconClearAll, IconSettings } from '@tabler/icons-react'; import { MutableRefObject, memo, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import toast from 'react-hot-toast'; import { useTranslation } from 'next-i18next'; import { getEndpoint } from '@/utils/app/api'; import { saveConversation, saveConversations, updateConversation, } from '@/utils/app/conversation'; import { throttle } from '@/utils/data/throttle'; import { ChatBody, Conversation, Message } from '@/types/chat'; import { Plugin } from '@/types/plugin'; import HomeContext from '@/pages/api/home/home.context'; import { ChatInput } from './ChatInput'; import { ChatLoader } from './ChatLoader'; import { ErrorMessageDiv } from './ErrorMessageDiv'; import { ModelSelect } from './ModelSelect'; import { SystemPrompt } from './SystemPrompt'; import { TemperatureSlider } from './Temperature'; import { MemoizedChatMessage } from './MemoizedChatMessage'; interface Props { stopConversationRef: MutableRefObject<boolean>; } export const Chat = memo(({ stopConversationRef }: Props) => { const { t } = useTranslation('chat'); const { state: { selectedConversation, conversations, models, apiKey, pluginKeys, serverSideApiKeyIsSet, messageIsStreaming, modelError, loading, prompts, }, handleUpdateConversation, dispatch: homeDispatch, } = useContext(HomeContext); const [currentMessage, setCurrentMessage] = useState<Message>(); const [autoScrollEnabled, setAutoScrollEnabled] = useState<boolean>(true); const [showSettings, setShowSettings] = useState<boolean>(false); const [showScrollDownButton, setShowScrollDownButton] = useState<boolean>(false); const messagesEndRef = useRef<HTMLDivElement>(null); const chatContainerRef = useRef<HTMLDivElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null); const handleSend = useCallback( async (message: Message, deleteCount = 0, plugin: Plugin | null = null) => { if (selectedConversation) { let updatedConversation: Conversation; if (deleteCount) { const updatedMessages = [...selectedConversation.messages]; for (let i = 0; i < deleteCount; i++) { updatedMessages.pop(); } updatedConversation = { ...selectedConversation, messages: [...updatedMessages, message], }; } else { updatedConversation = { ...selectedConversation, messages: [...selectedConversation.messages, message], }; } homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); homeDispatch({ field: 'loading', value: true }); homeDispatch({ field: 'messageIsStreaming', value: true }); const chatBody: ChatBody = { model: updatedConversation.model, messages: updatedConversation.messages, key: apiKey, prompt: updatedConversation.prompt, temperature: updatedConversation.temperature, }; const endpoint = getEndpoint(plugin); let body; if (!plugin) { body = JSON.stringify(chatBody); } else { body = JSON.stringify({ ...chatBody, googleAPIKey: pluginKeys .find((key) => key.pluginId === 'google-search') ?.requiredKeys.find((key) => key.key === 'GOOGLE_API_KEY')?.value, googleCSEId: pluginKeys .find((key) => key.pluginId === 'google-search') ?.requiredKeys.find((key) => key.key === 'GOOGLE_CSE_ID')?.value, }); } const controller = new AbortController(); const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, signal: controller.signal, body, }); if (!response.ok) { homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); toast.error(response.statusText); return; } const data = response.body; if (!data) { homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); return; } if (!plugin) { if (updatedConversation.messages.length === 1) { const { content } = message; const customName = content.length > 30 ? content.substring(0, 30) + '...' : content; updatedConversation = { ...updatedConversation, name: customName, }; } homeDispatch({ field: 'loading', value: false }); const reader = data.getReader(); const decoder = new TextDecoder(); let done = false; let isFirst = true; let text = ''; while (!done) { if (stopConversationRef.current === true) { controller.abort(); done = true; break; } const { value, done: doneReading } = await reader.read(); done = doneReading; const chunkValue = decoder.decode(value); text += chunkValue; if (isFirst) { isFirst = false; const updatedMessages: Message[] = [ ...updatedConversation.messages, { role: 'assistant', content: chunkValue }, ]; updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); } else { const updatedMessages: Message[] = updatedConversation.messages.map((message, index) => { if (index === updatedConversation.messages.length - 1) { return { ...message, content: text, }; } return message; }); updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updatedConversation, }); } } saveConversation(updatedConversation); const updatedConversations: Conversation[] = conversations.map( (conversation) => { if (conversation.id === selectedConversation.id) { return updatedConversation; } return conversation; }, ); if (updatedConversations.length === 0) { updatedConversations.push(updatedConversation); } homeDispatch({ field: 'conversations', value: updatedConversations }); saveConversations(updatedConversations); homeDispatch({ field: 'messageIsStreaming', value: false }); } else { const { answer } = await response.json(); const updatedMessages: Message[] = [ ...updatedConversation.messages, { role: 'assistant', content: answer }, ]; updatedConversation = { ...updatedConversation, messages: updatedMessages, }; homeDispatch({ field: 'selectedConversation', value: updateConversation, }); saveConversation(updatedConversation); const updatedConversations: Conversation[] = conversations.map( (conversation) => { if (conversation.id === selectedConversation.id) { return updatedConversation; } return conversation; }, ); if (updatedConversations.length === 0) { updatedConversations.push(updatedConversation); } homeDispatch({ field: 'conversations', value: updatedConversations }); saveConversations(updatedConversations); homeDispatch({ field: 'loading', value: false }); homeDispatch({ field: 'messageIsStreaming', value: false }); } } }, [ apiKey, conversations, pluginKeys, selectedConversation, stopConversationRef, ], ); const scrollToBottom = useCallback(() => { if (autoScrollEnabled) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); textareaRef.current?.focus(); } }, [autoScrollEnabled]); const handleScroll = () => { if (chatContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current; const bottomTolerance = 30; if (scrollTop + clientHeight < scrollHeight - bottomTolerance) { setAutoScrollEnabled(false); setShowScrollDownButton(true); } else { setAutoScrollEnabled(true); setShowScrollDownButton(false); } } }; const handleScrollDown = () => { chatContainerRef.current?.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: 'smooth', }); }; const handleSettings = () => { setShowSettings(!showSettings); }; const onClearAll = () => { if ( confirm(t<string>('Are you sure you want to clear all messages?')) && selectedConversation ) { handleUpdateConversation(selectedConversation, { key: 'messages', value: [], }); } }; const scrollDown = () => { if (autoScrollEnabled) { messagesEndRef.current?.scrollIntoView(true); } }; const throttledScrollDown = throttle(scrollDown, 250); // useEffect(() => { // console.log('currentMessage', currentMessage); // if (currentMessage) { // handleSend(currentMessage); // homeDispatch({ field: 'currentMessage', value: undefined }); // } // }, [currentMessage]); useEffect(() => { throttledScrollDown(); selectedConversation && setCurrentMessage( selectedConversation.messages[selectedConversation.messages.length - 2], ); }, [selectedConversation, throttledScrollDown]); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { setAutoScrollEnabled(entry.isIntersecting); if (entry.isIntersecting) { textareaRef.current?.focus(); } }, { root: null, threshold: 0.5, }, ); const messagesEndElement = messagesEndRef.current; if (messagesEndElement) { observer.observe(messagesEndElement); } return () => { if (messagesEndElement) { observer.unobserve(messagesEndElement); } }; }, [messagesEndRef]); return ( <div className="relative flex-1 overflow-hidden bg-white dark:bg-[#343541]"> {!(apiKey || serverSideApiKeyIsSet) ? ( <div className="mx-auto flex h-full w-[300px] flex-col justify-center space-y-6 sm:w-[600px]"> <div className="text-center text-4xl font-bold text-black dark:text-white"> Welcome to Chatbot UI </div> <div className="text-center text-lg text-black dark:text-white"> <div className="mb-8">{`Chatbot UI is an open source clone of OpenAI's ChatGPT UI.`}</div> <div className="mb-2 font-bold"> Important: Chatbot UI is 100% unaffiliated with OpenAI. </div> </div> <div className="text-center text-gray-500 dark:text-gray-400"> <div className="mb-2"> Chatbot UI allows you to plug in your base url to use this UI with your API. </div> <div className="mb-2"> It is <span className="italic">only</span> used to communicate with your API. </div> </div> </div> ) : modelError ? ( <ErrorMessageDiv error={modelError} /> ) : ( <> <div className="max-h-full overflow-x-hidden" ref={chatContainerRef} onScroll={handleScroll} > {selectedConversation?.messages.length === 0 ? ( <> <div className="mx-auto flex flex-col space-y-5 md:space-y-10 px-3 pt-5 md:pt-12 sm:max-w-[600px]"> <div className="text-center text-3xl font-semibold text-gray-800 dark:text-gray-100"> Chatbot UI </div> {models.length > 0 && ( <div className="flex h-full flex-col space-y-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-600"> <ModelSelect /> <SystemPrompt conversation={selectedConversation} prompts={prompts} onChangePrompt={(prompt) => handleUpdateConversation(selectedConversation, { key: 'prompt', value: prompt, }) } /> <TemperatureSlider label={t('Temperature')} onChangeTemperature={(temperature) => handleUpdateConversation(selectedConversation, { key: 'temperature', value: temperature, }) } /> </div> )} </div> </> ) : ( <> <div className="sticky top-0 z-10 flex justify-center border border-b-neutral-300 bg-neutral-100 py-2 text-sm text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200"> <button className="ml-2 cursor-pointer hover:opacity-50" onClick={handleSettings} > <IconSettings size={18} /> </button> <button className="ml-2 cursor-pointer hover:opacity-50" onClick={onClearAll} > <IconClearAll size={18} /> </button> </div> {showSettings && ( <div className="flex flex-col space-y-10 md:mx-auto md:max-w-xl md:gap-6 md:py-3 md:pt-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl"> <div className="flex h-full flex-col space-y-4 border-b border-neutral-200 p-4 dark:border-neutral-600 md:rounded-lg md:border"> <ModelSelect /> </div> </div> )} {selectedConversation?.messages.map((message, index) => ( <MemoizedChatMessage key={index} message={message} messageIndex={index} onEdit={(editedMessage) => { setCurrentMessage(editedMessage); // discard edited message and the ones that come after then resend handleSend( editedMessage, selectedConversation?.messages.length - index, ); }} /> ))} {loading && <ChatLoader />} <div className="h-[162px] bg-white dark:bg-[#343541]" ref={messagesEndRef} /> </> )} </div> <ChatInput stopConversationRef={stopConversationRef} textareaRef={textareaRef} onSend={(message, plugin) => { setCurrentMessage(message); handleSend(message, 0, plugin); }} onScrollDownClick={handleScrollDown} onRegenerate={() => { if (currentMessage) { handleSend(currentMessage, 2, null); } }} showScrollDownButton={showScrollDownButton} /> </> )} </div> ); }); Chat.displayName = 'Chat';