This commit is contained in:
Mckay Wrigley 2023-03-15 06:04:12 -06:00
parent e87136c4ec
commit 758a1215c2
5 changed files with 228 additions and 85 deletions

View File

@ -0,0 +1,39 @@
import { Conversation } from "@/types";
import { IconMessage, IconTrash } from "@tabler/icons-react";
import { FC } from "react";
interface Props {
conversations: Conversation[];
selectedConversation: Conversation;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
}
export const Conversations: FC<Props> = ({ conversations, selectedConversation, onSelectConversation, onDeleteConversation }) => {
return (
<div className="flex flex-col space-y-2">
{conversations.map((conversation, index) => (
<div
key={index}
className={`flex items-center justify-start w-[240px] h-[40px] px-2 text-sm rounded-lg hover:bg-neutral-700 cursor-pointer ${selectedConversation.id === conversation.id ? "bg-slate-600" : ""}`}
onClick={() => onSelectConversation(conversation)}
>
<IconMessage
className="mr-2 min-w-[20px]"
size={18}
/>
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis pr-1">{conversation.messages[0] ? conversation.messages[0].content : "Empty conversation"}</div>
<IconTrash
className="ml-auto min-w-[20px] text-neutral-400 hover:text-neutral-100"
size={18}
onClick={(e) => {
e.stopPropagation();
onDeleteConversation(conversation);
}}
/>
</div>
))}
</div>
);
};

View File

@ -11,7 +11,7 @@ export const ModelSelect: FC<Props> = ({ model, onSelect }) => {
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">Model</label> <label className="text-left mb-2 dark:text-neutral-400 text-neutral-700">Model</label>
<select <select
className="w-[300px] p-3 dark:text-white dark:bg-[#343541] border border-neutral-500 rounded-lg appearance-none focus:shadow-outline text-neutral-900" className="w-[300px] p-3 dark:text-white dark:bg-[#343541] border border-neutral-500 rounded-lg appearance-none focus:shadow-outline text-neutral-900 cursor-pointer"
placeholder="Select a model" placeholder="Select a model"
value={model} value={model}
onChange={(e) => onSelect(e.target.value as OpenAIModel)} onChange={(e) => onSelect(e.target.value as OpenAIModel)}

View File

@ -1,17 +1,27 @@
import { Conversation } from "@/types";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import { FC } from "react"; import { FC } from "react";
import { Conversations } from "../Conversations";
import { SidebarSettings } from "./SidebarSettings"; import { SidebarSettings } from "./SidebarSettings";
interface Props { interface Props {
conversations: Conversation[];
lightMode: "light" | "dark"; lightMode: "light" | "dark";
selectedConversation: Conversation;
onNewConversation: () => void;
onToggleLightMode: (mode: "light" | "dark") => void; onToggleLightMode: (mode: "light" | "dark") => void;
onSelectConversation: (conversation: Conversation) => void;
onDeleteConversation: (conversation: Conversation) => void;
} }
export const Sidebar: FC<Props> = ({ lightMode, onToggleLightMode }) => { export const Sidebar: FC<Props> = ({ conversations, lightMode, selectedConversation, onNewConversation, onToggleLightMode, onSelectConversation, onDeleteConversation }) => {
return ( return (
<div className="flex flex-col bg-[#202123] min-w-[260px]"> <div className="flex flex-col bg-[#202123] min-w-[260px]">
<div className="flex items-center justify-center h-[60px]"> <div className="flex items-center justify-center h-[60px]">
<button className="flex items-center w-[240px] h-[40px] rounded-lg bg-[#202123] border border-neutral-600 text-sm hover:bg-neutral-700"> <button
className="flex items-center w-[240px] h-[40px] rounded-lg bg-[#202123] border border-neutral-600 text-sm hover:bg-neutral-700"
onClick={onNewConversation}
>
<IconPlus <IconPlus
className="ml-4 mr-3" className="ml-4 mr-3"
size={16} size={16}
@ -20,7 +30,14 @@ export const Sidebar: FC<Props> = ({ lightMode, onToggleLightMode }) => {
</button> </button>
</div> </div>
<div className="flex-1"></div> <div className="flex-1 mx-auto pb-2 overflow-auto">
<Conversations
conversations={conversations}
selectedConversation={selectedConversation}
onSelectConversation={onSelectConversation}
onDeleteConversation={onDeleteConversation}
/>
</div>
<SidebarSettings <SidebarSettings
lightMode={lightMode} lightMode={lightMode}

View File

@ -1,80 +1,112 @@
import { Chat } from "@/components/Chat/Chat"; import { Chat } from "@/components/Chat/Chat";
import { Sidebar } from "@/components/Sidebar/Sidebar"; import { Sidebar } from "@/components/Sidebar/Sidebar";
import { Message, OpenAIModel } from "@/types"; import { Conversation, Message, OpenAIModel } from "@/types";
import Head from "next/head"; import Head from "next/head";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function Home() { export default function Home() {
const [messages, setMessages] = useState<Message[]>([]); const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation>();
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [model, setModel] = useState<OpenAIModel>(OpenAIModel.GPT_3_5); const [model, setModel] = useState<OpenAIModel>(OpenAIModel.GPT_3_5);
const [lightMode, setLightMode] = useState<"dark" | "light">("dark"); const [lightMode, setLightMode] = useState<"dark" | "light">("dark");
const handleSend = async (message: Message) => { const handleSend = async (message: Message) => {
const updatedMessages = [...messages, message]; if (selectedConversation) {
let updatedConversation: Conversation = {
...selectedConversation,
messages: [...selectedConversation.messages, message]
};
setMessages(updatedMessages); setSelectedConversation(updatedConversation);
setLoading(true); setLoading(true);
const response = await fetch("/api/chat", { const response = await fetch("/api/chat", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: updatedMessages messages: updatedConversation.messages
}) })
}); });
if (!response.ok) { if (!response.ok) {
setLoading(false); setLoading(false);
throw new Error(response.statusText); throw new Error(response.statusText);
}
const data = response.body;
if (!data) {
return;
}
setLoading(false);
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
let isFirst = true;
let text = "";
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = decoder.decode(value);
text += chunkValue;
if (isFirst) {
isFirst = false;
setMessages((messages) => [
...messages,
{
role: "assistant",
content: chunkValue
}
]);
} else {
setMessages((messages) => {
const lastMessage = messages[messages.length - 1];
const updatedMessage = {
...lastMessage,
content: lastMessage.content + chunkValue
};
return [...messages.slice(0, -1), updatedMessage];
});
} }
}
localStorage.setItem("messageHistory", JSON.stringify([...updatedMessages, { role: "assistant", content: text }])); const data = response.body;
if (!data) {
return;
}
setLoading(false);
const reader = data.getReader();
const decoder = new TextDecoder();
let done = false;
let isFirst = true;
let text = "";
while (!done) {
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
};
setSelectedConversation(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
};
setSelectedConversation(updatedConversation);
}
}
localStorage.setItem("selectedConversation", JSON.stringify(updatedConversation));
const updatedConversations: Conversation[] = conversations.map((conversation) => {
if (conversation.id === selectedConversation.id) {
return updatedConversation;
}
return conversation;
});
if (updatedConversations.length === 0) {
updatedConversations.push(updatedConversation);
}
setConversations(updatedConversations);
localStorage.setItem("conversationHistory", JSON.stringify(updatedConversations));
}
}; };
const handleLightMode = (mode: "dark" | "light") => { const handleLightMode = (mode: "dark" | "light") => {
@ -82,18 +114,61 @@ export default function Home() {
localStorage.setItem("theme", mode); localStorage.setItem("theme", mode);
}; };
const handleNewConversation = () => {
const newConversation: Conversation = {
id: conversations.length + 1,
name: "",
messages: []
};
const updatedConversations = [...conversations, newConversation];
setConversations(updatedConversations);
localStorage.setItem("conversationHistory", JSON.stringify(updatedConversations));
setSelectedConversation(newConversation);
localStorage.setItem("selectedConversation", JSON.stringify(newConversation));
setModel(OpenAIModel.GPT_3_5);
setLoading(false);
};
const handleSelectConversation = (conversation: Conversation) => {
setSelectedConversation(conversation);
localStorage.setItem("selectedConversation", JSON.stringify(conversation));
};
const handleDeleteConversation = (conversation: Conversation) => {
const updatedConversations = conversations.filter((c) => c.id !== conversation.id);
setConversations(updatedConversations);
localStorage.setItem("conversationHistory", JSON.stringify(updatedConversations));
if (selectedConversation && selectedConversation.id === conversation.id) {
setSelectedConversation(undefined);
localStorage.removeItem("selectedConversation");
}
};
useEffect(() => { useEffect(() => {
const theme = localStorage.getItem("theme"); const theme = localStorage.getItem("theme");
if (theme) { if (theme) {
setLightMode(theme as "dark" | "light"); setLightMode(theme as "dark" | "light");
} }
const messageHistory = localStorage.getItem("messageHistory"); const conversationHistory = localStorage.getItem("conversationHistory");
console.log(messageHistory);
if (messageHistory) { if (conversationHistory) {
setMessages(JSON.parse(messageHistory)); setConversations(JSON.parse(conversationHistory));
}
const selectedConversation = localStorage.getItem("selectedConversation");
if (selectedConversation) {
setSelectedConversation(JSON.parse(selectedConversation));
} else {
setSelectedConversation({
id: 1,
name: "",
messages: []
});
} }
}, []); }, []);
@ -114,23 +189,29 @@ export default function Home() {
href="/favicon.ico" href="/favicon.ico"
/> />
</Head> </Head>
{selectedConversation && (
<div className={`flex h-screen text-white ${lightMode}`}> <div className={`flex h-screen text-white ${lightMode}`}>
<Sidebar <Sidebar
lightMode={lightMode} conversations={conversations}
onToggleLightMode={handleLightMode} lightMode={lightMode}
/> selectedConversation={selectedConversation}
onToggleLightMode={handleLightMode}
<div className="flex flex-col w-full h-full dark:bg-[#343541]"> onNewConversation={handleNewConversation}
<Chat onSelectConversation={handleSelectConversation}
model={model} onDeleteConversation={handleDeleteConversation}
messages={messages}
loading={loading}
onSend={handleSend}
onSelect={setModel}
/> />
<div className="flex flex-col w-full h-full dark:bg-[#343541]">
<Chat
model={model}
messages={selectedConversation.messages}
loading={loading}
onSend={handleSend}
onSelect={setModel}
/>
</div>
</div> </div>
</div> )}
</> </>
); );
} }

View File

@ -16,3 +16,9 @@ export interface Message {
} }
export type Role = "assistant" | "user"; export type Role = "assistant" | "user";
export interface Conversation {
id: number;
name: string;
messages: Message[];
}