| 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 Spinner from '../Spinner'; |
| 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(() => { |
| 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 API key to use this UI with |
| their API. |
| </div> |
| <div className="mb-2"> |
| It is <span className="italic">only</span> used to communicate |
| with their API. |
| </div> |
| <div className="mb-2"> |
| {t( |
| 'Please set your OpenAI API key in the bottom left of the sidebar.', |
| )} |
| </div> |
| <div> |
| {t("If you don't have an OpenAI API key, you can get one here: ")} |
| <a |
| href="https://platform.openai.com/account/api-keys" |
| target="_blank" |
| rel="noreferrer" |
| className="text-blue-500 hover:underline" |
| > |
| openai.com |
| </a> |
| </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"> |
| {models.length === 0 ? ( |
| <div> |
| <Spinner size="16px" className="mx-auto" /> |
| </div> |
| ) : ( |
| '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"> |
| {t('Model')}: {selectedConversation?.model.name} | {t('Temp')} |
| : {selectedConversation?.temperature} | |
| <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'; |
|
|