* folders

* fixes

* storage fix
This commit is contained in:
Mckay Wrigley 2023-03-23 17:59:40 -06:00 committed by GitHub
parent 1a4b4401ee
commit f5118e3037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 677 additions and 226 deletions

View File

@ -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<boolean>;
}
export const Chat: FC<Props> = ({ conversation, models, apiKey, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, stopConversationRef }) => {
export const Chat: FC<Props> = ({ conversation, models, apiKey, isUsingEnv, messageIsStreaming, modelError, messageError, loading, lightMode, onSend, onUpdateConversation, onAcceptEnv, stopConversationRef }) => {
const [currentMessage, setCurrentMessage] = useState<Message>();
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
@ -29,7 +31,6 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, messageIsStreami
const chatContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollToBottom = () => {
if (autoScrollEnabled) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@ -51,8 +52,7 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, messageIsStreami
useEffect(() => {
scrollToBottom();
textareaRef.current?.focus()
textareaRef.current?.focus();
}, [conversation.messages]);
useEffect(() => {
@ -69,10 +69,17 @@ export const Chat: FC<Props> = ({ conversation, models, apiKey, messageIsStreami
return (
<div className="relative flex-1 overflow-none dark:bg-[#343541] bg-white">
{!apiKey ? (
{!apiKey && !isUsingEnv ? (
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6">
<div className="text-2xl font-semibold text-center text-gray-800 dark:text-gray-100">OpenAI API Key Required</div>
<div className="text-center text-gray-500 dark:text-gray-400">Please set your OpenAI API key in the bottom left of the sidebar.</div>
<div className="text-center text-gray-500 dark:text-gray-400">- OR -</div>
<button
className="flex items-center justify-center mx-auto px-4 py-2 border border-transparent text-xs rounded-md text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
onClick={() => onAcceptEnv(true)}
>
click if using a .env.local file
</button>
</div>
) : modelError ? (
<div className="flex flex-col justify-center mx-auto h-full w-[300px] sm:w-[500px] space-y-6">

View File

@ -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";

View File

@ -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<Props> = ({ selectedConversation, conversation, loading, onSelectConversation, onDeleteConversation, onUpdateConversation }) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleRename(selectedConversation);
}
};
const handleDragStart = (e: DragEvent<HTMLButtonElement>, 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 (
<button
className={`flex w-full gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`}
onClick={() => onSelectConversation(conversation)}
disabled={loading}
draggable="true"
onDragStart={(e) => handleDragStart(e, conversation)}
>
<IconMessage size={16} />
{isRenaming && selectedConversation.id === conversation.id ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div>
)}
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && (
<div className="flex gap-1 -ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(selectedConversation.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</button>
);
};

View File

@ -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<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onRenameConversation }) => {
const [isDeleting, setIsDeleting] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
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<Props> = ({ loading, conversations, selectedConversation, onSelectConversation, onDeleteConversation, onUpdateConversation }) => {
return (
<div className="flex flex-col-reverse gap-1 w-full pt-2">
{conversations.map((conversation, index) => (
<button
<ConversationComponent
key={index}
className={`flex gap-3 items-center p-3 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer ${loading ? "disabled:cursor-not-allowed" : ""} ${selectedConversation.id === conversation.id ? "bg-[#343541]/90" : ""}`}
onClick={() => onSelectConversation(conversation)}
disabled={loading}
>
<IconMessage
className=""
size={16}
/>
{isRenaming && selectedConversation.id === conversation.id ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{conversation.name}</div>
)}
{(isDeleting || isRenaming) && selectedConversation.id === conversation.id && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteConversation(conversation);
} else if (isRenaming) {
handleRename(conversation);
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{selectedConversation.id === conversation.id && !isDeleting && !isRenaming && (
<div className="flex gap-1 -ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(selectedConversation.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</button>
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
</div>
);

View File

@ -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<Props> = ({
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<HTMLDivElement>) => {
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 (
<div>
<div
className={`mb-1 flex gap-3 items-center px-3 py-2 text-sm rounded-lg hover:bg-[#343541]/90 transition-colors duration-200 cursor-pointer`}
onClick={() => setIsOpen(!isOpen)}
onDrop={(e) => handleDrop(e, currentFolder)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
{isOpen ? <IconCaretDown size={16} /> : <IconCaretRight size={16} />}
{isRenaming ? (
<input
className="flex-1 bg-transparent border-b border-neutral-400 focus:border-neutral-100 text-left overflow-hidden overflow-ellipsis pr-1 outline-none text-white"
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleEnterDown}
autoFocus
/>
) : (
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1 flex-1 text-left">{currentFolder.name}</div>
)}
{(isDeleting || isRenaming) && (
<div className="flex gap-1 -ml-2">
<IconCheck
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
if (isDeleting) {
onDeleteFolder(currentFolder.id);
} else if (isRenaming) {
handleRename();
}
setIsDeleting(false);
setIsRenaming(false);
}}
/>
<IconX
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={16}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(false);
setIsRenaming(false);
}}
/>
</div>
)}
{!isDeleting && !isRenaming && (
<div className="flex gap-1 ml-2">
<IconPencil
className="min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
setRenameValue(currentFolder.name);
}}
/>
<IconTrash
className=" min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
setIsDeleting(true);
}}
/>
</div>
)}
</div>
{isOpen
? conversations.map((conversation, index) => {
if (conversation.folderId === currentFolder.id) {
return (
<div
key={index}
className="ml-5 pl-2 border-l gap-2 pt-2"
>
<ConversationComponent
selectedConversation={selectedConversation}
conversation={conversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
</div>
);
}
})
: null}
</div>
);
};

View File

@ -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<Props> = ({
searchTerm,
conversations,
folders,
onDeleteFolder,
onUpdateFolder,
// conversation props
selectedConversation,
loading,
onSelectConversation,
onDeleteConversation,
onUpdateConversation
}) => {
return (
<div className="flex flex-col gap-1 w-full pt-2">
{folders.map((folder, index) => (
<Folder
key={index}
searchTerm={searchTerm}
conversations={conversations.filter((c) => c.folderId)}
currentFolder={folder}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
// conversation props
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
onUpdateConversation={onUpdateConversation}
/>
))}
</div>
);
};

View File

@ -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<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selectedConversation, apiKey, folders, onCreateFolder, onDeleteFolder, onUpdateFolder, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation, onToggleSidebar, onUpdateConversation, onApiKeyChange, onClearConversations, onExportConversations, onImportConversations }) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const [filteredConversations, setFilteredConversations] = useState<Conversation[]>(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<Props> = ({ loading, conversations, lightMode, selected
<aside className={`h-full flex flex-none space-y-2 p-2 flex-col bg-[#202123] w-[260px] z-10 sm:relative sm:top-0 absolute top-12 bottom-0`}>
<header className="flex items-center">
<button
className="flex gap-3 p-3 items-center w-full sm:w-[200px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
className="flex gap-3 p-3 items-center w-[190px] rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
onClick={() => {
onNewConversation();
setSearchTerm("");
}}
>
<IconPlus
className=""
size={16}
/>
<IconPlus size={16} />
New chat
</button>
<button
className="ml-2 flex gap-3 p-3 items-center rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm flex-shrink-0 border border-white/20"
onClick={() => onCreateFolder("New folder")}
>
<IconFolderPlus size={16} />
</button>
<IconArrowBarLeft
className="ml-1 p-1 text-neutral-300 cursor-pointer hover:text-neutral-400 hidden sm:flex"
size={32}
@ -71,20 +112,45 @@ export const Sidebar: FC<Props> = ({ loading, conversations, lightMode, selected
)}
<div className="flex-grow overflow-auto">
<Conversations
loading={loading}
conversations={filteredConversations}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={(conversation) => {
onDeleteConversation(conversation);
setSearchTerm("");
}}
onRenameConversation={(conversation, name) => {
onUpdateConversation(conversation, { key: "name", value: name });
setSearchTerm("");
}}
/>
{folders.length > 0 && (
<div className="flex border-b border-white/20 pb-2">
<Folders
searchTerm={searchTerm}
conversations={filteredConversations.filter((conversation) => conversation.folderId !== 0)}
folders={folders}
onDeleteFolder={onDeleteFolder}
onUpdateFolder={onUpdateFolder}
selectedConversation={selectedConversation}
loading={loading}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
)}
{conversations.length > 0 ? (
<div
className="pt-2 h-full"
onDrop={(e) => handleDrop(e)}
onDragOver={allowDrop}
onDragEnter={highlightDrop}
onDragLeave={removeHighlight}
>
<Conversations
loading={loading}
conversations={filteredConversations.filter((conversation) => conversation.folderId === 0)}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={handleDeleteConversation}
onUpdateConversation={handleUpdateConversation}
/>
</div>
) : (
<div className="mt-4 text-white text-center">
<div>No conversations.</div>
</div>
)}
</div>
<SidebarSettings

View File

@ -1,16 +1,18 @@
import { Chat } from "@/components/Chat/Chat";
import { Navbar } from "@/components/Mobile/Navbar";
import { Sidebar } from "@/components/Sidebar/Sidebar";
import { ChatBody, Conversation, KeyValuePair, Message, OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types";
import { ChatBody, ChatFolder, Conversation, KeyValuePair, Message, OpenAIModel, OpenAIModelID, OpenAIModels } from "@/types";
import { cleanConversationHistory, cleanSelectedConversation } from "@/utils/app/clean";
import { DEFAULT_SYSTEM_PROMPT } from "@/utils/app/const";
import { saveConversation, saveConversations, updateConversation } from "@/utils/app/conversation";
import { exportConversations, importConversations } from "@/utils/app/data";
import { saveFolders } from "@/utils/app/folders";
import { exportConversations, importConversations } from "@/utils/app/importExport";
import { IconArrowBarLeft, IconArrowBarRight } from "@tabler/icons-react";
import Head from "next/head";
import { useEffect, useRef, useState } from "react";
export default function Home() {
const [folders, setFolders] = useState<ChatFolder[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation>();
const [loading, setLoading] = useState<boolean>(false);
@ -21,6 +23,8 @@ export default function Home() {
const [apiKey, setApiKey] = useState<string>("");
const [messageError, setMessageError] = useState<boolean>(false);
const [modelError, setModelError] = useState<boolean>(false);
const [isUsingEnv, setIsUsingEnv] = useState<boolean>(false);
const stopConversationRef = useRef<boolean>(false);
const handleSend = async (message: Message, isResend: boolean) => {
@ -201,6 +205,11 @@ export default function Home() {
localStorage.setItem("apiKey", apiKey);
};
const handleEnvChange = (isUsingEnv: boolean) => {
setIsUsingEnv(isUsingEnv);
localStorage.setItem("isUsingEnv", isUsingEnv.toString());
};
const handleExportConversations = () => {
exportConversations();
};
@ -216,6 +225,55 @@ export default function Home() {
saveConversation(conversation);
};
const handleCreateFolder = (name: string) => {
const lastFolder = folders[folders.length - 1];
const newFolder: ChatFolder = {
id: lastFolder ? lastFolder.id + 1 : 1,
name
};
const updatedFolders = [...folders, newFolder];
setFolders(updatedFolders);
saveFolders(updatedFolders);
};
const handleDeleteFolder = (folderId: number) => {
const updatedFolders = folders.filter((f) => f.id !== folderId);
setFolders(updatedFolders);
saveFolders(updatedFolders);
const updatedConversations: Conversation[] = conversations.map((c) => {
if (c.folderId === folderId) {
return {
...c,
folderId: 0
};
}
return c;
});
setConversations(updatedConversations);
saveConversations(updatedConversations);
};
const handleUpdateFolder = (folderId: number, name: string) => {
const updatedFolders = folders.map((f) => {
if (f.id === folderId) {
return {
...f,
name
};
}
return f;
});
setFolders(updatedFolders);
saveFolders(updatedFolders);
};
const handleNewConversation = () => {
const lastConversation = conversations[conversations.length - 1];
@ -224,7 +282,8 @@ export default function Home() {
name: `Conversation ${lastConversation ? lastConversation.id + 1 : 1}`,
messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0
};
const updatedConversations = [...conversations, newConversation];
@ -252,7 +311,8 @@ export default function Home() {
name: "New conversation",
messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0
});
localStorage.removeItem("selectedConversation");
}
@ -279,9 +339,16 @@ export default function Home() {
name: "New conversation",
messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0
});
localStorage.removeItem("selectedConversation");
setFolders([]);
localStorage.removeItem("folders");
setIsUsingEnv(false);
localStorage.removeItem("isUsingEnv");
};
useEffect(() => {
@ -308,10 +375,21 @@ export default function Home() {
fetchModels(apiKey);
}
const usingEnv = localStorage.getItem("isUsingEnv");
if (usingEnv) {
setIsUsingEnv(usingEnv === "true");
fetchModels("");
}
if (window.innerWidth < 640) {
setShowSidebar(false);
}
const folders = localStorage.getItem("folders");
if (folders) {
setFolders(JSON.parse(folders));
}
const conversationHistory = localStorage.getItem("conversationHistory");
if (conversationHistory) {
const parsedConversationHistory: Conversation[] = JSON.parse(conversationHistory);
@ -330,7 +408,8 @@ export default function Home() {
name: "New conversation",
messages: [],
model: OpenAIModels[OpenAIModelID.GPT_3_5],
prompt: DEFAULT_SYSTEM_PROMPT
prompt: DEFAULT_SYSTEM_PROMPT,
folderId: 0
});
}
}, []);
@ -370,7 +449,11 @@ export default function Home() {
lightMode={lightMode}
selectedConversation={selectedConversation}
apiKey={apiKey}
folders={folders}
onToggleLightMode={handleLightMode}
onCreateFolder={handleCreateFolder}
onDeleteFolder={handleDeleteFolder}
onUpdateFolder={handleUpdateFolder}
onNewConversation={handleNewConversation}
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
@ -398,6 +481,7 @@ export default function Home() {
conversation={selectedConversation}
messageIsStreaming={messageIsStreaming}
apiKey={apiKey}
isUsingEnv={isUsingEnv}
modelError={modelError}
messageError={messageError}
models={models}
@ -405,6 +489,7 @@ export default function Home() {
lightMode={lightMode}
onSend={handleSend}
onUpdateConversation={handleUpdateConversation}
onAcceptEnv={handleEnvChange}
stopConversationRef={stopConversationRef}
/>
</article>

View File

@ -26,12 +26,18 @@ export interface Message {
export type Role = "assistant" | "user";
export interface ChatFolder {
id: number;
name: string;
}
export interface Conversation {
id: number;
name: string;
messages: Message[];
model: OpenAIModel;
prompt: string;
folderId: number;
}
export interface ChatBody {
@ -52,4 +58,6 @@ export interface LocalStorage {
conversationHistory: Conversation[];
selectedConversation: Conversation;
theme: "light" | "dark";
// added folders (3/23/23)
folders: ChatFolder[];
}

View File

@ -4,6 +4,7 @@ import { DEFAULT_SYSTEM_PROMPT } from "./const";
export const cleanSelectedConversation = (conversation: Conversation) => {
// added model for each conversation (3/20/23)
// added system prompt for each conversation (3/21/23)
// added folders (3/23/23)
let updatedConversation = conversation;
@ -11,7 +12,7 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
if (!updatedConversation.model) {
updatedConversation = {
...updatedConversation,
model: OpenAIModels[OpenAIModelID.GPT_3_5]
model: updatedConversation.model || OpenAIModels[OpenAIModelID.GPT_3_5]
};
}
@ -19,7 +20,14 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
if (!updatedConversation.prompt) {
updatedConversation = {
...updatedConversation,
prompt: DEFAULT_SYSTEM_PROMPT
prompt: updatedConversation.prompt || DEFAULT_SYSTEM_PROMPT
};
}
if (!updatedConversation.folderId) {
updatedConversation = {
...updatedConversation,
folderId: updatedConversation.folderId || 0
};
}
@ -29,24 +37,23 @@ export const cleanSelectedConversation = (conversation: Conversation) => {
export const cleanConversationHistory = (history: Conversation[]) => {
// added model for each conversation (3/20/23)
// added system prompt for each conversation (3/21/23)
// added folders (3/23/23)
let updatedHistory = [...history];
// check for model on each conversation
if (!updatedHistory.every((conversation) => conversation.model)) {
updatedHistory = updatedHistory.map((conversation) => ({
...conversation,
model: OpenAIModels[OpenAIModelID.GPT_3_5]
}));
}
updatedHistory.forEach((conversation) => {
if (!conversation.model) {
conversation.model = OpenAIModels[OpenAIModelID.GPT_3_5];
}
// check for system prompt on each conversation
if (!updatedHistory.every((conversation) => conversation.prompt)) {
updatedHistory = updatedHistory.map((conversation) => ({
...conversation,
systemPrompt: DEFAULT_SYSTEM_PROMPT
}));
}
if (!conversation.prompt) {
conversation.prompt = DEFAULT_SYSTEM_PROMPT;
}
if (!conversation.folderId) {
conversation.folderId = 0;
}
});
return updatedHistory;
};

39
utils/app/codeblock.ts Normal file
View File

@ -0,0 +1,39 @@
interface languageMap {
[key: string]: string | undefined;
}
export const programmingLanguages: languageMap = {
javascript: ".js",
python: ".py",
java: ".java",
c: ".c",
cpp: ".cpp",
"c++": ".cpp",
"c#": ".cs",
ruby: ".rb",
php: ".php",
swift: ".swift",
"objective-c": ".m",
kotlin: ".kt",
typescript: ".ts",
go: ".go",
perl: ".pl",
rust: ".rs",
scala: ".scala",
haskell: ".hs",
lua: ".lua",
shell: ".sh",
sql: ".sql",
html: ".html",
css: ".css"
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
};
export const generateRandomString = (length: Number, lowercase = false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return lowercase ? result.toLowerCase() : result;
};

View File

@ -1,64 +0,0 @@
import { Conversation } from "@/types";
export const exportConversations = () => {
const history = localStorage.getItem("conversationHistory");
if (!history) return;
const blob = new Blob([history], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = "chatbot_ui_history.json";
link.href = url;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const importConversations = (conversations: Conversation[]) => {
localStorage.setItem("conversationHistory", JSON.stringify(conversations));
localStorage.setItem("selectedConversation", JSON.stringify(conversations[conversations.length - 1]));
};
interface languageMap {
[key: string]: string | undefined
}
export const programmingLanguages: languageMap = {
'javascript': '.js',
'python': '.py',
'java': '.java',
'c': '.c',
'cpp': '.cpp',
'c++': '.cpp',
'c#': '.cs',
'ruby': '.rb',
'php': '.php',
'swift': '.swift',
'objective-c': '.m',
'kotlin': '.kt',
'typescript': '.ts',
'go': '.go',
'perl': '.pl',
'rust': '.rs',
'scala': '.scala',
'haskell': '.hs',
'lua': '.lua',
'shell': '.sh',
'sql': '.sql',
'html': '.html',
'css': '.css'
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
};
export const generateRandomString = (length: Number, lowercase=false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return lowercase ? result.toLowerCase() : result;
}

5
utils/app/folders.ts Normal file
View File

@ -0,0 +1,5 @@
import { ChatFolder } from "@/types";
export const saveFolders = (folders: ChatFolder[]) => {
localStorage.setItem("folders", JSON.stringify(folders));
};

23
utils/app/importExport.ts Normal file
View File

@ -0,0 +1,23 @@
import { Conversation } from "@/types";
export const exportConversations = () => {
const history = localStorage.getItem("conversationHistory");
if (!history) return;
const blob = new Blob([history], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = "chatbot_ui_history.json";
link.href = url;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const importConversations = (conversations: Conversation[]) => {
localStorage.setItem("conversationHistory", JSON.stringify(conversations));
localStorage.setItem("selectedConversation", JSON.stringify(conversations[conversations.length - 1]));
};