91cbe0b104
* fix: scroll button not visible in light mode * fix: sidebar when there is a folder When a folder is added in the sidebar and there are less items scroll bar appears. This simple change fixes that behaviour * fix: small devices regerate/Stop button below input Below 768px Stop Genrating and Regerate Button remains hidden behind the input. This is the fix for that
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { Conversation, Message } from '@/types/chat';
|
|
import { KeyValuePair } from '@/types/data';
|
|
import { ErrorMessage } from '@/types/error';
|
|
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
|
|
import { Prompt } from '@/types/prompt';
|
|
import { throttle } from '@/utils';
|
|
import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react';
|
|
import { useTranslation } from 'next-i18next';
|
|
import {
|
|
FC,
|
|
memo,
|
|
MutableRefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { Spinner } from '../Global/Spinner';
|
|
import { ChatInput } from './ChatInput';
|
|
import { ChatLoader } from './ChatLoader';
|
|
import { ChatMessage } from './ChatMessage';
|
|
import { ErrorMessageDiv } from './ErrorMessageDiv';
|
|
import { ModelSelect } from './ModelSelect';
|
|
import { SystemPrompt } from './SystemPrompt';
|
|
|
|
interface Props {
|
|
conversation: Conversation;
|
|
models: OpenAIModel[];
|
|
apiKey: string;
|
|
serverSideApiKeyIsSet: boolean;
|
|
defaultModelId: OpenAIModelID;
|
|
messageIsStreaming: boolean;
|
|
modelError: ErrorMessage | null;
|
|
loading: boolean;
|
|
prompts: Prompt[];
|
|
onSend: (message: Message, deleteCount?: number) => void;
|
|
onUpdateConversation: (
|
|
conversation: Conversation,
|
|
data: KeyValuePair,
|
|
) => void;
|
|
onEditMessage: (message: Message, messageIndex: number) => void;
|
|
stopConversationRef: MutableRefObject<boolean>;
|
|
}
|
|
|
|
export const Chat: FC<Props> = memo(
|
|
({
|
|
conversation,
|
|
models,
|
|
apiKey,
|
|
serverSideApiKeyIsSet,
|
|
defaultModelId,
|
|
messageIsStreaming,
|
|
modelError,
|
|
loading,
|
|
prompts,
|
|
onSend,
|
|
onUpdateConversation,
|
|
onEditMessage,
|
|
stopConversationRef,
|
|
}) => {
|
|
const { t } = useTranslation('chat');
|
|
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 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?'))) {
|
|
onUpdateConversation(conversation, { key: 'messages', value: [] });
|
|
}
|
|
};
|
|
|
|
const scrollDown = () => {
|
|
if (autoScrollEnabled) {
|
|
messagesEndRef.current?.scrollIntoView(true);
|
|
}
|
|
};
|
|
const throttledScrollDown = throttle(scrollDown, 250);
|
|
|
|
// appear scroll down button only when user scrolls up
|
|
|
|
useEffect(() => {
|
|
throttledScrollDown();
|
|
setCurrentMessage(
|
|
conversation.messages[conversation.messages.length - 2],
|
|
);
|
|
}, [conversation.messages, 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}
|
|
>
|
|
{conversation.messages.length === 0 ? (
|
|
<>
|
|
<div className="mx-auto flex w-[350px] flex-col space-y-10 pt-12 sm: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
|
|
model={conversation.model}
|
|
models={models}
|
|
defaultModelId={defaultModelId}
|
|
onModelChange={(model) =>
|
|
onUpdateConversation(conversation, {
|
|
key: 'model',
|
|
value: model,
|
|
})
|
|
}
|
|
/>
|
|
|
|
<SystemPrompt
|
|
conversation={conversation}
|
|
prompts={prompts}
|
|
onChangePrompt={(prompt) =>
|
|
onUpdateConversation(conversation, {
|
|
key: 'prompt',
|
|
value: prompt,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="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')}: {conversation.model.name}
|
|
<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
|
|
model={conversation.model}
|
|
models={models}
|
|
defaultModelId={defaultModelId}
|
|
onModelChange={(model) =>
|
|
onUpdateConversation(conversation, {
|
|
key: 'model',
|
|
value: model,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{conversation.messages.map((message, index) => (
|
|
<ChatMessage
|
|
key={index}
|
|
message={message}
|
|
messageIndex={index}
|
|
onEditMessage={onEditMessage}
|
|
/>
|
|
))}
|
|
|
|
{loading && <ChatLoader />}
|
|
|
|
<div
|
|
className="h-[162px] bg-white dark:bg-[#343541]"
|
|
ref={messagesEndRef}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<ChatInput
|
|
stopConversationRef={stopConversationRef}
|
|
textareaRef={textareaRef}
|
|
messageIsStreaming={messageIsStreaming}
|
|
conversationIsEmpty={conversation.messages.length === 0}
|
|
messages={conversation.messages}
|
|
model={conversation.model}
|
|
prompts={prompts}
|
|
onSend={(message) => {
|
|
setCurrentMessage(message);
|
|
onSend(message);
|
|
}}
|
|
onRegenerate={() => {
|
|
if (currentMessage) {
|
|
onSend(currentMessage, 2);
|
|
}
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
{showScrollDownButton && (
|
|
<div className="absolute bottom-0 right-0 mb-4 mr-4 pb-20">
|
|
<button
|
|
className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-300 text-gray-800 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-neutral-200"
|
|
onClick={handleScrollDown}
|
|
>
|
|
<IconArrowDown size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
Chat.displayName = 'Chat';
|