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; } 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(); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const [showSettings, setShowSettings] = useState(false); const [showScrollDownButton, setShowScrollDownButton] = useState(false); const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); const textareaRef = useRef(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('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 (
{!(apiKey || serverSideApiKeyIsSet) ? (
Welcome to Chatbot UI
{`Chatbot UI is an open source clone of OpenAI's ChatGPT UI.`}
Important: Chatbot UI is 100% unaffiliated with OpenAI.
Chatbot UI allows you to plug in your base url to use this UI with your API.
It is only used to communicate with your API.
) : modelError ? ( ) : ( <>
{selectedConversation?.messages.length === 0 ? ( <>
Chatbot UI
{models.length > 0 && (
handleUpdateConversation(selectedConversation, { key: 'prompt', value: prompt, }) } /> handleUpdateConversation(selectedConversation, { key: 'temperature', value: temperature, }) } />
)}
) : ( <>
{showSettings && (
)} {selectedConversation?.messages.map((message, index) => ( { setCurrentMessage(editedMessage); // discard edited message and the ones that come after then resend handleSend( editedMessage, selectedConversation?.messages.length - index, ); }} /> ))} {loading && }
)}
{ setCurrentMessage(message); handleSend(message, 0, plugin); }} onScrollDownClick={handleScrollDown} onRegenerate={() => { if (currentMessage) { handleSend(currentMessage, 2, null); } }} showScrollDownButton={showScrollDownButton} /> )}
); }); Chat.displayName = 'Chat';