From 34c79c0d66c188a2c6c6113067f5ac8fee66a472 Mon Sep 17 00:00:00 2001 From: Mckay Wrigley Date: Mon, 27 Mar 2023 09:38:56 -0600 Subject: [PATCH] Prompts (#229) --- components/Chat/Chat.tsx | 54 +++- components/Chat/ChatInput.tsx | 186 +++++++++++- components/Chat/ChatMessage.tsx | 8 +- components/Chat/CopyButton.tsx | 17 +- components/Chat/ErrorMessageDiv.tsx | 4 +- components/Chat/ModelSelect.tsx | 4 +- components/Chat/PromptList.tsx | 50 ++++ components/Chat/Regenerate.tsx | 2 +- components/Chat/SystemPrompt.tsx | 182 ++++++++++- components/Chat/VariableModal.tsx | 123 ++++++++ .../Sidebar.tsx => Chatbar/Chatbar.tsx} | 62 ++-- .../ChatbarSettings.tsx} | 15 +- .../ClearConversations.tsx | 4 +- .../{Sidebar => Chatbar}/Conversation.tsx | 11 +- .../{Sidebar => Chatbar}/Conversations.tsx | 30 +- .../Chat/ChatFolder.tsx} | 18 +- .../Chat/ChatFolders.tsx} | 16 +- components/Folders/Prompt/PromptFolder.tsx | 197 ++++++++++++ components/Folders/Prompt/PromptFolders.tsx | 44 +++ components/Markdown/CodeBlock.tsx | 2 +- components/Mobile/Navbar.tsx | 2 +- components/Promptbar/Prompt.tsx | 123 ++++++++ components/Promptbar/PromptModal.tsx | 117 ++++++++ components/Promptbar/Promptbar.tsx | 175 +++++++++++ components/Promptbar/PromptbarSettings.tsx | 7 + components/Promptbar/Prompts.tsx | 31 ++ components/{Sidebar => Settings}/Import.tsx | 7 +- components/{Sidebar => Settings}/Key.tsx | 4 +- components/Sidebar/Search.tsx | 9 +- components/Sidebar/SidebarButton.tsx | 2 +- package-lock.json | 29 +- package.json | 4 +- pages/api/chat.ts | 3 +- pages/api/models.ts | 2 +- pages/index.tsx | 282 +++++++++++++----- types/chat.ts | 24 ++ types/data.ts | 4 + types/env.d.ts | 6 - types/env.ts | 4 + types/error.ts | 5 + types/folder.ts | 7 + types/index.ts | 70 +---- types/openai.ts | 20 ++ types/prompt.ts | 10 + types/storage.ts | 18 ++ utils/app/clean.ts | 9 +- utils/app/conversation.ts | 2 +- utils/app/folders.ts | 4 +- utils/app/importExport.ts | 5 +- utils/app/prompts.ts | 22 ++ utils/server/index.ts | 3 +- 51 files changed, 1744 insertions(+), 295 deletions(-) create mode 100644 components/Chat/PromptList.tsx create mode 100644 components/Chat/VariableModal.tsx rename components/{Sidebar/Sidebar.tsx => Chatbar/Chatbar.tsx} (78%) rename components/{Sidebar/SidebarSettings.tsx => Chatbar/ChatbarSettings.tsx} (84%) rename components/{Sidebar => Chatbar}/ClearConversations.tsx (96%) rename components/{Sidebar => Chatbar}/Conversation.tsx (93%) rename components/{Sidebar => Chatbar}/Conversations.tsx (50%) rename components/{Sidebar/Folder.tsx => Folders/Chat/ChatFolder.tsx} (91%) rename components/{Sidebar/Folders.tsx => Folders/Chat/ChatFolders.tsx} (78%) create mode 100644 components/Folders/Prompt/PromptFolder.tsx create mode 100644 components/Folders/Prompt/PromptFolders.tsx create mode 100644 components/Promptbar/Prompt.tsx create mode 100644 components/Promptbar/PromptModal.tsx create mode 100644 components/Promptbar/Promptbar.tsx create mode 100644 components/Promptbar/PromptbarSettings.tsx create mode 100644 components/Promptbar/Prompts.tsx rename components/{Sidebar => Settings}/Import.tsx (89%) rename components/{Sidebar => Settings}/Key.tsx (97%) create mode 100644 types/chat.ts create mode 100644 types/data.ts delete mode 100644 types/env.d.ts create mode 100644 types/env.ts create mode 100644 types/error.ts create mode 100644 types/folder.ts create mode 100644 types/openai.ts create mode 100644 types/prompt.ts create mode 100644 types/storage.ts create mode 100644 utils/app/prompts.ts diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index 04c572c..bd854cf 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -1,14 +1,20 @@ -import { - Conversation, - ErrorMessage, - KeyValuePair, - Message, - OpenAIModel, -} from '@/types'; +import { Conversation, Message } from '@/types/chat'; +import { KeyValuePair } from '@/types/data'; +import { ErrorMessage } from '@/types/error'; +import { OpenAIModel } from '@/types/openai'; +import { Prompt } from '@/types/prompt'; import { throttle } from '@/utils'; import { IconClearAll, IconKey, IconSettings } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; -import { FC, memo, MutableRefObject, useEffect, useRef, useState } from 'react'; +import { + FC, + memo, + MutableRefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { Spinner } from '../Global/Spinner'; import { ChatInput } from './ChatInput'; import { ChatLoader } from './ChatLoader'; @@ -24,8 +30,8 @@ interface Props { serverSideApiKeyIsSet: boolean; messageIsStreaming: boolean; modelError: ErrorMessage | null; - messageError: boolean; loading: boolean; + prompts: Prompt[]; onSend: (message: Message, deleteCount?: number) => void; onUpdateConversation: ( conversation: Conversation, @@ -43,8 +49,8 @@ export const Chat: FC = memo( serverSideApiKeyIsSet, messageIsStreaming, modelError, - messageError, loading, + prompts, onSend, onUpdateConversation, onEditMessage, @@ -59,6 +65,27 @@ export const Chat: FC = memo( const chatContainerRef = useRef(null); const textareaRef = useRef(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); + } else { + setAutoScrollEnabled(true); + } + } + }; + const handleSettings = () => { setShowSettings(!showSettings); }; @@ -174,6 +201,7 @@ export const Chat: FC = memo( onUpdateConversation(conversation, { key: 'prompt', @@ -201,8 +229,8 @@ export const Chat: FC = memo( /> {showSettings && ( -
-
+
+
= memo( textareaRef={textareaRef} messageIsStreaming={messageIsStreaming} conversationIsEmpty={conversation.messages.length === 0} + messages={conversation.messages} model={conversation.model} + prompts={prompts} onSend={(message) => { setCurrentMessage(message); onSend(message); diff --git a/components/Chat/ChatInput.tsx b/components/Chat/ChatInput.tsx index 39759cf..618db80 100644 --- a/components/Chat/ChatInput.tsx +++ b/components/Chat/ChatInput.tsx @@ -1,18 +1,26 @@ -import { Message, OpenAIModel, OpenAIModelID } from '@/types'; +import { Message } from '@/types/chat'; +import { OpenAIModel, OpenAIModelID } from '@/types/openai'; +import { Prompt } from '@/types/prompt'; import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import { FC, KeyboardEvent, MutableRefObject, + useCallback, useEffect, + useRef, useState, } from 'react'; +import { PromptList } from './PromptList'; +import { VariableModal } from './VariableModal'; interface Props { messageIsStreaming: boolean; model: OpenAIModel; conversationIsEmpty: boolean; + messages: Message[]; + prompts: Prompt[]; onSend: (message: Message) => void; onRegenerate: () => void; stopConversationRef: MutableRefObject; @@ -23,14 +31,28 @@ export const ChatInput: FC = ({ messageIsStreaming, model, conversationIsEmpty, + messages, + prompts, onSend, onRegenerate, stopConversationRef, textareaRef, }) => { const { t } = useTranslation('chat'); + const [content, setContent] = useState(); const [isTyping, setIsTyping] = useState(false); + const [showPromptList, setShowPromptList] = useState(false); + const [activePromptIndex, setActivePromptIndex] = useState(0); + const [promptInputValue, setPromptInputValue] = useState(''); + const [variables, setVariables] = useState([]); + const [isModalVisible, setIsModalVisible] = useState(false); + + const promptListRef = useRef(null); + + const filteredPrompts = prompts.filter((prompt) => + prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()), + ); const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -47,6 +69,7 @@ export const ChatInput: FC = ({ } setContent(value); + updatePromptListVisibility(value); }; const handleSend = () => { @@ -67,6 +90,13 @@ export const ChatInput: FC = ({ } }; + const handleStopConversation = () => { + stopConversationRef.current = true; + setTimeout(() => { + stopConversationRef.current = false; + }, 1000); + }; + const isMobile = () => { const userAgent = typeof window.navigator === 'undefined' ? '' : navigator.userAgent; @@ -75,15 +105,106 @@ export const ChatInput: FC = ({ return mobileRegex.test(userAgent); }; + const handleInitModal = () => { + const selectedPrompt = filteredPrompts[activePromptIndex]; + setContent((prevContent) => { + const newContent = prevContent?.replace(/\/\w*$/, selectedPrompt.content); + return newContent; + }); + handlePromptSelect(selectedPrompt); + setShowPromptList(false); + }; + const handleKeyDown = (e: KeyboardEvent) => { - if (!isTyping) { - if (e.key === 'Enter' && !e.shiftKey && !isMobile()) { + if (showPromptList) { + if (e.key === 'ArrowDown') { e.preventDefault(); - handleSend(); + setActivePromptIndex((prevIndex) => + prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActivePromptIndex((prevIndex) => + prevIndex > 0 ? prevIndex - 1 : prevIndex, + ); + } else if (e.key === 'Tab') { + e.preventDefault(); + setActivePromptIndex((prevIndex) => + prevIndex < prompts.length - 1 ? prevIndex + 1 : 0, + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + handleInitModal(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setShowPromptList(false); + } else { + setActivePromptIndex(0); } + } else if (e.key === 'Enter' && !isMobile() && !e.shiftKey) { + e.preventDefault(); + handleSend(); } }; + const parseVariables = (content: string) => { + const regex = /{{(.*?)}}/g; + const foundVariables = []; + let match; + + while ((match = regex.exec(content)) !== null) { + foundVariables.push(match[1]); + } + + return foundVariables; + }; + + const updatePromptListVisibility = useCallback((text: string) => { + const match = text.match(/\/\w*$/); + + if (match) { + setShowPromptList(true); + setPromptInputValue(match[0].slice(1)); + } else { + setShowPromptList(false); + setPromptInputValue(''); + } + }, []); + + const handlePromptSelect = (prompt: Prompt) => { + const parsedVariables = parseVariables(prompt.content); + setVariables(parsedVariables); + + if (parsedVariables.length > 0) { + setIsModalVisible(true); + } else { + setContent((prevContent) => { + const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content); + return updatedContent; + }); + updatePromptListVisibility(prompt.content); + } + }; + + const handleSubmit = (updatedVariables: string[]) => { + const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => { + const index = variables.indexOf(variable); + return updatedVariables[index]; + }); + + setContent(newContent); + + if (textareaRef && textareaRef.current) { + textareaRef.current.focus(); + } + }; + + useEffect(() => { + if (promptListRef.current) { + promptListRef.current.scrollTop = activePromptIndex * 30; + } + }, [activePromptIndex]); + useEffect(() => { if (textareaRef && textareaRef.current) { textareaRef.current.style.height = 'inherit'; @@ -94,19 +215,29 @@ export const ChatInput: FC = ({ } }, [content]); - function handleStopConversation() { - stopConversationRef.current = true; - setTimeout(() => { - stopConversationRef.current = false; - }, 1000); - } + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if ( + promptListRef.current && + !promptListRef.current.contains(e.target as Node) + ) { + setShowPromptList(false); + } + }; + + window.addEventListener('click', handleOutsideClick); + + return () => { + window.removeEventListener('click', handleOutsideClick); + }; + }, []); return (
{messageIsStreaming && ( )} -
+