From f5118e30375ac9091050061faafdd3abef0e5600 Mon Sep 17 00:00:00 2001 From: Mckay Wrigley Date: Thu, 23 Mar 2023 17:59:40 -0600 Subject: [PATCH] folders (#108) * folders * fixes * storage fix --- components/Chat/Chat.tsx | 17 ++- components/Markdown/CodeBlock.tsx | 2 +- components/Sidebar/Conversation.tsx | 124 +++++++++++++++++ components/Sidebar/Conversations.tsx | 120 ++--------------- components/Sidebar/Folder.tsx | 193 +++++++++++++++++++++++++++ components/Sidebar/Folders.tsx | 52 ++++++++ components/Sidebar/Sidebar.tsx | 120 +++++++++++++---- pages/index.tsx | 97 +++++++++++++- types/index.ts | 8 ++ utils/app/clean.ts | 39 +++--- utils/app/codeblock.ts | 39 ++++++ utils/app/data.ts | 64 --------- utils/app/folders.ts | 5 + utils/app/importExport.ts | 23 ++++ 14 files changed, 677 insertions(+), 226 deletions(-) create mode 100644 components/Sidebar/Conversation.tsx create mode 100644 components/Sidebar/Folder.tsx create mode 100644 components/Sidebar/Folders.tsx create mode 100644 utils/app/codeblock.ts delete mode 100644 utils/app/data.ts create mode 100644 utils/app/folders.ts create mode 100644 utils/app/importExport.ts diff --git a/components/Chat/Chat.tsx b/components/Chat/Chat.tsx index 6a67f52..9bb6c7e 100644 --- a/components/Chat/Chat.tsx +++ b/components/Chat/Chat.tsx @@ -11,6 +11,7 @@ interface Props { conversation: Conversation; models: OpenAIModel[]; apiKey: string; + isUsingEnv: boolean; messageIsStreaming: boolean; modelError: boolean; messageError: boolean; @@ -18,10 +19,11 @@ interface Props { lightMode: "light" | "dark"; onSend: (message: Message, isResend: boolean) => void; onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; + onAcceptEnv: (accept: boolean) => void; stopConversationRef: MutableRefObject; } -export const Chat: FC = ({ conversation, models, apiKey, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, stopConversationRef }) => { +export const Chat: FC = ({ conversation, models, apiKey, isUsingEnv, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, onAcceptEnv, stopConversationRef }) => { const [currentMessage, setCurrentMessage] = useState(); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); @@ -29,7 +31,6 @@ export const Chat: FC = ({ conversation, models, apiKey, messageIsStreami const chatContainerRef = useRef(null); const textareaRef = useRef(null); - const scrollToBottom = () => { if (autoScrollEnabled) { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -51,8 +52,7 @@ export const Chat: FC = ({ conversation, models, apiKey, messageIsStreami useEffect(() => { scrollToBottom(); - textareaRef.current?.focus() - + textareaRef.current?.focus(); }, [conversation.messages]); useEffect(() => { @@ -69,10 +69,17 @@ export const Chat: FC = ({ conversation, models, apiKey, messageIsStreami return (
- {!apiKey ? ( + {!apiKey && !isUsingEnv ? (
OpenAI API Key Required
Please set your OpenAI API key in the bottom left of the sidebar.
+
- OR -
+
) : modelError ? (
diff --git a/components/Markdown/CodeBlock.tsx b/components/Markdown/CodeBlock.tsx index e4cf103..9f3ca5f 100644 --- a/components/Markdown/CodeBlock.tsx +++ b/components/Markdown/CodeBlock.tsx @@ -1,4 +1,4 @@ -import { generateRandomString, programmingLanguages } from "@/utils/app/data"; +import { generateRandomString, programmingLanguages } from "@/utils/app/codeblock"; import { IconDownload } from "@tabler/icons-react"; import { FC, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; diff --git a/components/Sidebar/Conversation.tsx b/components/Sidebar/Conversation.tsx new file mode 100644 index 0000000..fff639d --- /dev/null +++ b/components/Sidebar/Conversation.tsx @@ -0,0 +1,124 @@ +import { Conversation, KeyValuePair } from "@/types"; +import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react"; +import { DragEvent, FC, KeyboardEvent, useEffect, useState } from "react"; + +interface Props { + selectedConversation: Conversation; + conversation: Conversation; + loading: boolean; + onSelectConversation: (conversation: Conversation) => void; + onDeleteConversation: (conversation: Conversation) => void; + onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; +} + +export const ConversationComponent: FC = ({ selectedConversation, conversation, loading, onSelectConversation, onDeleteConversation, onUpdateConversation }) => { + const [isDeleting, setIsDeleting] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + + const handleEnterDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleRename(selectedConversation); + } + }; + + const handleDragStart = (e: DragEvent, conversation: Conversation) => { + if (e.dataTransfer) { + e.dataTransfer.setData("conversation", JSON.stringify(conversation)); + } + }; + + const handleRename = (conversation: Conversation) => { + onUpdateConversation(conversation, { key: "name", value: renameValue }); + setRenameValue(""); + setIsRenaming(false); + }; + + useEffect(() => { + if (isRenaming) { + setIsDeleting(false); + } else if (isDeleting) { + setIsRenaming(false); + } + }, [isRenaming, isDeleting]); + + return ( + + ); +}; diff --git a/components/Sidebar/Conversations.tsx b/components/Sidebar/Conversations.tsx index 648307b..48578b3 100644 --- a/components/Sidebar/Conversations.tsx +++ b/components/Sidebar/Conversations.tsx @@ -1,6 +1,6 @@ -import { Conversation } from "@/types"; -import { IconCheck, IconMessage, IconPencil, IconTrash, IconX } from "@tabler/icons-react"; -import { FC, KeyboardEvent, useEffect, useState } from "react"; +import { Conversation, KeyValuePair } from "@/types"; +import { FC } from "react"; +import { ConversationComponent } from "./Conversation"; interface Props { loading: boolean; @@ -8,116 +8,22 @@ interface Props { selectedConversation: Conversation; onSelectConversation: (conversation: Conversation) => void; onDeleteConversation: (conversation: Conversation) => void; - onRenameConversation: (conversation: Conversation, name: string) => void; + onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; } -export const Conversations: FC = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onRenameConversation }) => { - const [isDeleting, setIsDeleting] = useState(false); - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(""); - - const handleEnterDown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleRename(selectedConversation); - } - }; - - const handleRename = (conversation: Conversation) => { - onRenameConversation(conversation, renameValue); - setRenameValue(""); - setIsRenaming(false); - }; - - useEffect(() => { - if (isRenaming) { - setIsDeleting(false); - } else if (isDeleting) { - setIsRenaming(false); - } - }, [isRenaming, isDeleting]); - +export const Conversations: FC = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onUpdateConversation }) => { return (
{conversations.map((conversation, index) => ( - + selectedConversation={selectedConversation} + conversation={conversation} + loading={loading} + onSelectConversation={onSelectConversation} + onDeleteConversation={onDeleteConversation} + onUpdateConversation={onUpdateConversation} + /> ))}
); diff --git a/components/Sidebar/Folder.tsx b/components/Sidebar/Folder.tsx new file mode 100644 index 0000000..e7dd311 --- /dev/null +++ b/components/Sidebar/Folder.tsx @@ -0,0 +1,193 @@ +import { ChatFolder, Conversation, KeyValuePair } from "@/types"; +import { IconCaretDown, IconCaretRight, IconCheck, IconPencil, IconTrash, IconX } from "@tabler/icons-react"; +import { FC, KeyboardEvent, useEffect, useState } from "react"; +import { ConversationComponent } from "./Conversation"; + +interface Props { + searchTerm: string; + conversations: Conversation[]; + currentFolder: ChatFolder; + onDeleteFolder: (folder: number) => void; + onUpdateFolder: (folder: number, name: string) => void; + // conversation props + selectedConversation: Conversation; + loading: boolean; + onSelectConversation: (conversation: Conversation) => void; + onDeleteConversation: (conversation: Conversation) => void; + onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; +} + +export const Folder: FC = ({ + searchTerm, + conversations, + currentFolder, + onDeleteFolder, + onUpdateFolder, + // conversation props + selectedConversation, + loading, + onSelectConversation, + onDeleteConversation, + onUpdateConversation +}) => { + const [isDeleting, setIsDeleting] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [isOpen, setIsOpen] = useState(false); + + const handleEnterDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleRename(); + } + }; + + const handleRename = () => { + onUpdateFolder(currentFolder.id, renameValue); + setRenameValue(""); + setIsRenaming(false); + }; + + const handleDrop = (e: any, folder: ChatFolder) => { + if (e.dataTransfer) { + setIsOpen(true); + + const conversation = JSON.parse(e.dataTransfer.getData("conversation")); + onUpdateConversation(conversation, { key: "folderId", value: folder.id }); + + e.target.style.background = "none"; + } + }; + + const allowDrop = (e: any) => { + e.preventDefault(); + }; + + const highlightDrop = (e: any) => { + e.target.style.background = "#343541"; + }; + + const removeHighlight = (e: any) => { + e.target.style.background = "none"; + }; + + useEffect(() => { + if (isRenaming) { + setIsDeleting(false); + } else if (isDeleting) { + setIsRenaming(false); + } + }, [isRenaming, isDeleting]); + + useEffect(() => { + if (searchTerm) { + setIsOpen(true); + } else { + setIsOpen(false); + } + }, [searchTerm]); + + return ( +
+
setIsOpen(!isOpen)} + onDrop={(e) => handleDrop(e, currentFolder)} + onDragOver={allowDrop} + onDragEnter={highlightDrop} + onDragLeave={removeHighlight} + > + {isOpen ? : } + + {isRenaming ? ( + setRenameValue(e.target.value)} + onKeyDown={handleEnterDown} + autoFocus + /> + ) : ( +
{currentFolder.name}
+ )} + + {(isDeleting || isRenaming) && ( +
+ { + e.stopPropagation(); + + if (isDeleting) { + onDeleteFolder(currentFolder.id); + } else if (isRenaming) { + handleRename(); + } + + setIsDeleting(false); + setIsRenaming(false); + }} + /> + + { + e.stopPropagation(); + setIsDeleting(false); + setIsRenaming(false); + }} + /> +
+ )} + + {!isDeleting && !isRenaming && ( +
+ { + e.stopPropagation(); + setIsRenaming(true); + setRenameValue(currentFolder.name); + }} + /> + + { + e.stopPropagation(); + setIsDeleting(true); + }} + /> +
+ )} +
+ + {isOpen + ? conversations.map((conversation, index) => { + if (conversation.folderId === currentFolder.id) { + return ( +
+ +
+ ); + } + }) + : null} +
+ ); +}; diff --git a/components/Sidebar/Folders.tsx b/components/Sidebar/Folders.tsx new file mode 100644 index 0000000..69e38a1 --- /dev/null +++ b/components/Sidebar/Folders.tsx @@ -0,0 +1,52 @@ +import { ChatFolder, Conversation, KeyValuePair } from "@/types"; +import { FC } from "react"; +import { Folder } from "./Folder"; + +interface Props { + searchTerm: string; + conversations: Conversation[]; + folders: ChatFolder[]; + onDeleteFolder: (folder: number) => void; + onUpdateFolder: (folder: number, name: string) => void; + // conversation props + selectedConversation: Conversation; + loading: boolean; + onSelectConversation: (conversation: Conversation) => void; + onDeleteConversation: (conversation: Conversation) => void; + onUpdateConversation: (conversation: Conversation, data: KeyValuePair) => void; +} + +export const Folders: FC = ({ + searchTerm, + conversations, + folders, + onDeleteFolder, + onUpdateFolder, + // conversation props + selectedConversation, + loading, + onSelectConversation, + onDeleteConversation, + onUpdateConversation +}) => { + return ( +
+ {folders.map((folder, index) => ( + c.folderId)} + currentFolder={folder} + onDeleteFolder={onDeleteFolder} + onUpdateFolder={onUpdateFolder} + // conversation props + selectedConversation={selectedConversation} + loading={loading} + onSelectConversation={onSelectConversation} + onDeleteConversation={onDeleteConversation} + onUpdateConversation={onUpdateConversation} + /> + ))} +
+ ); +}; diff --git a/components/Sidebar/Sidebar.tsx b/components/Sidebar/Sidebar.tsx index 03fc41c..fd0abbe 100644 --- a/components/Sidebar/Sidebar.tsx +++ b/components/Sidebar/Sidebar.tsx @@ -1,7 +1,8 @@ -import { Conversation, KeyValuePair } from "@/types"; -import { IconArrowBarLeft, IconPlus } from "@tabler/icons-react"; +import { ChatFolder, Conversation, KeyValuePair } from "@/types"; +import { IconArrowBarLeft, IconFolderPlus, IconPlus } from "@tabler/icons-react"; import { FC, useEffect, useState } from "react"; import { Conversations } from "./Conversations"; +import { Folders } from "./Folders"; import { Search } from "./Search"; import { SidebarSettings } from "./SidebarSettings"; @@ -11,6 +12,10 @@ interface Props { lightMode: "light" | "dark"; selectedConversation: Conversation; apiKey: string; + folders: ChatFolder[]; + onCreateFolder: (name: string) => void; + onDeleteFolder: (folderId: number) => void; + onUpdateFolder: (folderId: number, name: string) => void; onNewConversation: () => void; onToggleLightMode: (mode: "light" | "dark") => void; onSelectConversation: (conversation: Conversation) => void; @@ -23,17 +28,49 @@ interface Props { onImportConversations: (conversations: Conversation[]) => void; } -export const Sidebar: FC = ({ loading, conversations, lightMode, selectedConversation, apiKey, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => { +export const Sidebar: FC = ({ loading, conversations, lightMode, selectedConversation, apiKey, folders, onCreateFolder, onDeleteFolder, onUpdateFolder, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => { const [searchTerm, setSearchTerm] = useState(""); const [filteredConversations, setFilteredConversations] = useState(conversations); + const handleUpdateConversation = (conversation: Conversation, data: KeyValuePair) => { + onUpdateConversation(conversation, data); + setSearchTerm(""); + }; + + const handleDeleteConversation = (conversation: Conversation) => { + onDeleteConversation(conversation); + setSearchTerm(""); + }; + + const handleDrop = (e: any) => { + if (e.dataTransfer) { + const conversation = JSON.parse(e.dataTransfer.getData("conversation")); + onUpdateConversation(conversation, { key: "folderId", value: 0 }); + + e.target.style.background = "none"; + } + }; + + const allowDrop = (e: any) => { + e.preventDefault(); + }; + + const highlightDrop = (e: any) => { + e.target.style.background = "#343541"; + }; + + const removeHighlight = (e: any) => { + e.target.style.background = "none"; + }; + useEffect(() => { if (searchTerm) { - setFilteredConversations(conversations.filter((conversation) => { - const searchable = conversation.name.toLocaleLowerCase() + ' ' + conversation.messages.map((message) => message.content).join(" "); - return searchable.toLowerCase().includes(searchTerm.toLowerCase()); - } - )); + setFilteredConversations( + conversations.filter((conversation) => { + const searchable = conversation.name.toLocaleLowerCase() + " " + conversation.messages.map((message) => message.content).join(" "); + return searchable.toLowerCase().includes(searchTerm.toLowerCase()); + }) + ); } else { setFilteredConversations(conversations); } @@ -43,19 +80,23 @@ export const Sidebar: FC = ({ loading, conversations, lightMode, selected