diff --git a/.github/workflows/deploy-docker-image.yaml b/.github/workflows/deploy-docker-image.yaml index 9059d18..11b9b39 100644 --- a/.github/workflows/deploy-docker-image.yaml +++ b/.github/workflows/deploy-docker-image.yaml @@ -7,7 +7,7 @@ name: Docker on: push: - branches: [ "main" ] + branches: ['main'] env: # Use docker.io for Docker Hub if empty @@ -15,10 +15,8 @@ env: # github.repository as / IMAGE_NAME: ${{ github.repository }} - jobs: build: - runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/run-test-suite.yml b/.github/workflows/run-test-suite.yml index e66ff1f..c0914db 100644 --- a/.github/workflows/run-test-suite.yml +++ b/.github/workflows/run-test-suite.yml @@ -14,11 +14,11 @@ jobs: image: node:16 steps: - - name: Checkout code - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v2 - - name: Install dependencies - run: npm ci + - name: Install dependencies + run: npm ci - - name: Run Vitest Suite - run: npm test + - name: Run Vitest Suite + run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3d56c7..2fc8637 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,5 @@ # Contributing Guidelines + **Welcome to Chatbot UI!** We appreciate your interest in contributing to our project. @@ -6,6 +7,7 @@ We appreciate your interest in contributing to our project. Before you get started, please read our guidelines for contributing. ## Types of Contributions + We welcome the following types of contributions: - Bug fixes @@ -15,8 +17,8 @@ We welcome the following types of contributions: - Translations - Tests - ## Getting Started + To get started, fork the project on GitHub and clone it locally on your machine. Then, create a new branch to work on your changes. ``` @@ -29,6 +31,7 @@ git checkout -b my-branch-name Before submitting your pull request, please make sure your changes pass our automated tests and adhere to our code style guidelines. ## Pull Request Process + 1. Fork the project on GitHub. 2. Clone your forked repository locally on your machine. 3. Create a new branch from the main branch. @@ -38,4 +41,5 @@ Before submitting your pull request, please make sure your changes pass our auto 7. Submit a pull request to the main branch of the main repository. ## Contact -If you have any questions or need help getting started, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley). \ No newline at end of file + +If you have any questions or need help getting started, feel free to reach out to me on [Twitter](https://twitter.com/mckaywrigley). diff --git a/__tests__/utils/app/importExports.test.ts b/__tests__/utils/app/importExports.test.ts index 1593b70..ee9c725 100644 --- a/__tests__/utils/app/importExports.test.ts +++ b/__tests__/utils/app/importExports.test.ts @@ -1,8 +1,4 @@ -import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export'; -import { OpenAIModels, OpenAIModelID } from '@/types/openai'; import { DEFAULT_SYSTEM_PROMPT } from '@/utils/app/const'; -import { it, describe, expect } from 'vitest'; - import { cleanData, isExportFormatV1, @@ -12,6 +8,11 @@ import { isLatestExportFormat, } from '@/utils/app/importExport'; +import { ExportFormatV1, ExportFormatV2, ExportFormatV4 } from '@/types/export'; +import { OpenAIModelID, OpenAIModels } from '@/types/openai'; + +import { describe, expect, it } from 'vitest'; + describe('Export Format Functions', () => { describe('isExportFormatV1', () => { it('should return true for v1 format', () => { @@ -105,7 +106,7 @@ describe('cleanData Functions', () => { }, ], folders: [], - prompts:[] + prompts: [], }); }); }); @@ -212,7 +213,7 @@ describe('cleanData Functions', () => { }, ], } as ExportFormatV4; - + const obj = cleanData(data); expect(isLatestExportFormat(obj)).toBe(true); expect(obj).toEqual({ @@ -253,9 +254,7 @@ describe('cleanData Functions', () => { folderId: null, }, ], - }); }); }); - }); diff --git a/components/Buttons/SidebarActionButton/SidebarActionButton.tsx b/components/Buttons/SidebarActionButton/SidebarActionButton.tsx new file mode 100644 index 0000000..2fdc79d --- /dev/null +++ b/components/Buttons/SidebarActionButton/SidebarActionButton.tsx @@ -0,0 +1,17 @@ +import { MouseEventHandler, ReactElement } from 'react'; + +interface Props { + handleClick: MouseEventHandler; + children: ReactElement; +} + +const SidebarActionButton = ({ handleClick, children }: Props) => ( + +); + +export default SidebarActionButton; diff --git a/components/Buttons/SidebarActionButton/index.ts b/components/Buttons/SidebarActionButton/index.ts new file mode 100644 index 0000000..1fce00e --- /dev/null +++ b/components/Buttons/SidebarActionButton/index.ts @@ -0,0 +1 @@ +export { default } from './SidebarActionButton'; diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index c7dcf52..9611ec2 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -1,22 +1,31 @@ -import { Conversation, Message } from '@/types/chat'; -import { KeyValuePair } from '@/types/data'; -import { ErrorMessage } from '@/types/error'; -import { OpenAIModel, OpenAIModelID } from '@/types/openai'; -import { Plugin } from '@/types/plugin'; -import { Prompt } from '@/types/prompt'; -import { throttle } from '@/utils'; import { IconArrowDown, IconClearAll, IconSettings } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; import { - FC, MutableRefObject, memo, useCallback, + useContext, useEffect, useRef, useState, } from 'react'; -import { Spinner } from '../Global/Spinner'; +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 { ChatMessage } from './ChatMessage'; @@ -25,310 +34,466 @@ 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, - plugin: Plugin | null, - ) => void; - onUpdateConversation: ( - conversation: Conversation, - data: KeyValuePair, - ) => void; - onEditMessage: (message: Message, messageIndex: number) => void; stopConversationRef: MutableRefObject; } -export const Chat: FC = memo( - ({ - conversation, - models, - apiKey, - serverSideApiKeyIsSet, - defaultModelId, - messageIsStreaming, - modelError, - loading, - prompts, - onSend, - onUpdateConversation, - onEditMessage, - stopConversationRef, - }) => { - const { t } = useTranslation('chat'); - const [currentMessage, setCurrentMessage] = useState(); - const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); - const [showSettings, setShowSettings] = useState(false); - const [showScrollDownButton, setShowScrollDownButton] = - useState(false); +export const Chat = memo(({ stopConversationRef }: Props) => { + const { t } = useTranslation('chat'); - const messagesEndRef = useRef(null); - const chatContainerRef = useRef(null); - const textareaRef = useRef(null); + const { + state: { + selectedConversation, + conversations, + models, + apiKey, + pluginKeys, + serverSideApiKeyIsSet, + messageIsStreaming, + modelError, + loading, + prompts, + }, + handleUpdateConversation, + dispatch: homeDispatch, + } = useContext(HomeContext); - const scrollToBottom = useCallback(() => { - if (autoScrollEnabled) { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - textareaRef.current?.focus(); - } - }, [autoScrollEnabled]); + const [currentMessage, setCurrentMessage] = useState(); + const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); + const [showSettings, setShowSettings] = useState(false); + const [showScrollDownButton, setShowScrollDownButton] = + useState(false); - const handleScroll = () => { - if (chatContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = - chatContainerRef.current; - const bottomTolerance = 30; + const messagesEndRef = useRef(null); + const chatContainerRef = useRef(null); + const textareaRef = useRef(null); - 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?'))) { - onUpdateConversation(conversation, { key: 'messages', value: [] }); - } - }; - - const scrollDown = () => { - if (autoScrollEnabled) { - messagesEndRef.current?.scrollIntoView(true); - } - }; - const throttledScrollDown = throttle(scrollDown, 250); - - 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(); + 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(); } - }, - { - root: null, - threshold: 0.5, - }, - ); - const messagesEndElement = messagesEndRef.current; - if (messagesEndElement) { - observer.observe(messagesEndElement); - } - return () => { - if (messagesEndElement) { - observer.unobserve(messagesEndElement); + updatedConversation = { + ...selectedConversation, + messages: [...updatedMessages, message], + }; + } else { + updatedConversation = { + ...selectedConversation, + messages: [...selectedConversation.messages, message], + }; } - }; - }, [messagesEndRef]); + 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, + }; + 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, + ], + ); - 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 API key to use this UI - with their API. -
-
- It is only used to communicate - with their API. -
-
- {t( - 'Please set your OpenAI API key in the bottom left of the sidebar.', - )} -
-
- {t( - "If you don't have an OpenAI API key, you can get one here: ", - )} - - openai.com - -
+ 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.
- ) : modelError ? ( - - ) : ( - <> -
- {conversation.messages.length === 0 ? ( - <> -
-
- {models.length === 0 ? ( -
- -
- ) : ( - 'Chatbot UI' - )} -
- - {models.length > 0 && ( -
- - onUpdateConversation(conversation, { - key: 'model', - value: model, - }) - } - /> - - - onUpdateConversation(conversation, { - key: 'prompt', - value: prompt, - }) - } - /> -
- )} -
- - ) : ( - <> -
- {t('Model')}: {conversation.model.name} - - -
- {showSettings && ( -
-
- - onUpdateConversation(conversation, { - key: 'model', - value: model, - }) - } - /> -
-
- )} - - {conversation.messages.map((message, index) => ( - - ))} - - {loading && } - -
- +
+
+ Chatbot UI allows you to plug in your API key to use this UI with + their API. +
+
+ It is only used to communicate + with their API. +
+
+ {t( + 'Please set your OpenAI API key in the bottom left of the sidebar.', )}
- - { - setCurrentMessage(message); - onSend(message, 0, plugin); - }} - onRegenerate={() => { - if (currentMessage) { - onSend(currentMessage, 2, null); - } - }} - /> - - )} - {showScrollDownButton && ( -
- +
+ {t("If you don't have an OpenAI API key, you can get one here: ")} + + openai.com + +
- )} -
- ); - }, -); +
+ ) : modelError ? ( + + ) : ( + <> +
+ {selectedConversation?.messages.length === 0 ? ( + <> +
+
+ {models.length === 0 ? ( +
+ +
+ ) : ( + 'Chatbot UI' + )} +
+ + {models.length > 0 && ( +
+ + + + handleUpdateConversation(selectedConversation, { + key: 'prompt', + value: prompt, + }) + } + /> +
+ )} +
+ + ) : ( + <> +
+ {t('Model')}: {selectedConversation?.model.name} + + +
+ {showSettings && ( +
+
+ +
+
+ )} + + {selectedConversation?.messages.map((message, index) => ( + + ))} + + {loading && } + +
+ + )} +
+ + { + setCurrentMessage(message); + handleSend(message, 0, plugin); + }} + onRegenerate={() => { + if (currentMessage) { + handleSend(currentMessage, 2, null); + } + }} + /> + + )} + {showScrollDownButton && ( +
+ +
+ )} +
+ ); +}); Chat.displayName = 'Chat'; diff --git a/components/Chat/ChatInput.tsx b/components/Chat/ChatInput.tsx index e5fc421..1399f73 100644 --- a/components/Chat/ChatInput.tsx +++ b/components/Chat/ChatInput.tsx @@ -1,7 +1,3 @@ -import { Message } from '@/types/chat'; -import { OpenAIModel } from '@/types/openai'; -import { Plugin } from '@/types/plugin'; -import { Prompt } from '@/types/prompt'; import { IconBolt, IconBrandGoogle, @@ -9,43 +5,49 @@ import { IconRepeat, IconSend, } from '@tabler/icons-react'; -import { useTranslation } from 'next-i18next'; import { - FC, KeyboardEvent, MutableRefObject, useCallback, + useContext, useEffect, useRef, useState, } from 'react'; + +import { useTranslation } from 'next-i18next'; + +import { Message } from '@/types/chat'; +import { Plugin } from '@/types/plugin'; +import { Prompt } from '@/types/prompt'; + +import HomeContext from '@/pages/api/home/home.context'; + import { PluginSelect } from './PluginSelect'; import { PromptList } from './PromptList'; import { VariableModal } from './VariableModal'; interface Props { - messageIsStreaming: boolean; - model: OpenAIModel; - conversationIsEmpty: boolean; - prompts: Prompt[]; onSend: (message: Message, plugin: Plugin | null) => void; onRegenerate: () => void; stopConversationRef: MutableRefObject; textareaRef: MutableRefObject; } -export const ChatInput: FC = ({ - messageIsStreaming, - model, - conversationIsEmpty, - prompts, +export const ChatInput = ({ onSend, onRegenerate, stopConversationRef, textareaRef, -}) => { +}: Props) => { const { t } = useTranslation('chat'); + const { + state: { selectedConversation, messageIsStreaming, prompts }, + + dispatch: homeDispatch, + } = useContext(HomeContext); + const [content, setContent] = useState(); const [isTyping, setIsTyping] = useState(false); const [showPromptList, setShowPromptList] = useState(false); @@ -64,9 +66,9 @@ export const ChatInput: FC = ({ const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; - const maxLength = model.maxLength; + const maxLength = selectedConversation?.model.maxLength; - if (value.length > maxLength) { + if (maxLength && value.length > maxLength) { alert( t( `Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`, @@ -261,14 +263,16 @@ export const ChatInput: FC = ({ )} - {!messageIsStreaming && !conversationIsEmpty && ( - - )} + {!messageIsStreaming && + selectedConversation && + selectedConversation.messages.length > 0 && ( + + )}