diff options
| author | Yash <[email protected]> | 2024-04-09 13:47:27 +0000 |
|---|---|---|
| committer | Yash <[email protected]> | 2024-04-09 13:47:27 +0000 |
| commit | 1693f33b01aeda720c1cf0ff721780c14a6a66c3 (patch) | |
| tree | cdbfc3d269a6725d1a6063100a07dd1afe7ea11c /apps/web/src | |
| parent | // (diff) | |
| download | supermemory-1693f33b01aeda720c1cf0ff721780c14a6a66c3.tar.xz supermemory-1693f33b01aeda720c1cf0ff721780c14a6a66c3.zip | |
chat implementation
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/ChatMessage.tsx | 95 | ||||
| -rw-r--r-- | apps/web/src/components/Main.tsx | 177 | ||||
| -rw-r--r-- | apps/web/src/lib/utils.ts | 4 |
3 files changed, 194 insertions, 82 deletions
diff --git a/apps/web/src/components/ChatMessage.tsx b/apps/web/src/components/ChatMessage.tsx index c6ee662f..5d24d23b 100644 --- a/apps/web/src/components/ChatMessage.tsx +++ b/apps/web/src/components/ChatMessage.tsx @@ -1,25 +1,106 @@ -import React from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; -import { User } from "next-auth"; -import { User2 } from "lucide-react"; -import Image from "next/image"; +import React, { useEffect } from "react"; +import { motion } from "framer-motion"; +import { ArrowUpRight, Globe } from "lucide-react"; +import { convertRemToPixels } from "@/lib/utils"; export function ChatAnswer({ children: message, sources, + loading = false, }: { children: string; sources?: string[]; + loading?: boolean; }) { - return <div className="mt-5 w-full text-lg">{message}</div>; + return ( + <div className="flex w-full flex-col items-start gap-5"> + {loading ? ( + <MessageSkeleton /> + ) : ( + <div className="w-full text-lg text-white/60">{message}</div> + )} + {sources && sources?.length > 0 && ( + <> + <h1 className="animate-fade-in text-md flex items-center justify-center gap-1 opacity-0 [animation-duration:1s]"> + <ArrowUpRight className="h-5 w-5" /> + Sources + </h1> + <div className="animate-fade-in -mt-3 flex items-center justify-start opacity-0 [animation-duration:1s]"> + {sources?.map((source) => ( + <a + className="bg-rgray-3 flex items-center justify-center gap-2 rounded-full py-1 pl-2 pr-3 text-sm" + key={source} + href={source} + > + <Globe className="h-4 w-4" /> + {source} + </a> + ))} + </div> + </> + )} + </div> + ); } export function ChatQuestion({ children }: { children: string }) { return ( <div - className={`text-rgray-12 w-full text-left ${children.length > 200 ? "text-xl" : "text-2xl"} font-light`} + className={`text-rgray-12 w-full text-left ${children.length > 200 ? "text-xl" : "text-2xl"}`} + > + {children} + </div> + ); +} + +export function ChatMessage({ + children, + isLast = false, + index, +}: { + children: React.ReactNode | React.ReactNode[]; + isLast?: boolean; + index: number; +}) { + const messageRef = React.useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!isLast) return; + console.log( + "last", + messageRef.current?.offsetTop, + messageRef.current?.parentElement, + ); + messageRef.current?.parentElement?.scrollTo({ + top: messageRef.current?.offsetTop, + behavior: "smooth", + }); + }, []); + + return ( + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ + type: "tween", + duration: 0.5, + }} + ref={messageRef} + className={`${index === 0 ? "pt-16" : "pt-28"} flex w-full flex-col items-start justify-start gap-5 transition-[height] ${isLast ? "min-h-screen" : "h-auto"}`} > {children} + </motion.div> + ); +} + +function MessageSkeleton() { + return ( + <div className="animate-fade-in flex w-full flex-col items-start gap-3 opacity-0 [animation-delay:0.5s] [animation-duration:1s]"> + <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div> + <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div> + <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div> + <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div> + <div className="bg-rgray-5 h-6 w-[70%] animate-pulse rounded-md text-lg"></div> </div> ); } diff --git a/apps/web/src/components/Main.tsx b/apps/web/src/components/Main.tsx index a3e6e7d1..aaa87a69 100644 --- a/apps/web/src/components/Main.tsx +++ b/apps/web/src/components/Main.tsx @@ -8,9 +8,35 @@ import useViewport from "@/hooks/useViewport"; import { AnimatePresence, motion } from "framer-motion"; import { cn, countLines } from "@/lib/utils"; import { ChatHistory } from "../../types/memory"; -import { ChatAnswer, ChatQuestion } from "./ChatMessage"; +import { ChatAnswer, ChatMessage, ChatQuestion } from "./ChatMessage"; import { useSession } from "next-auth/react"; -import { Card, CardContent } from "./ui/card"; + +const dummyChatHistory: ChatHistory = { + question: "What is the capital of France?", + answer: { + parts: [ + { + text: "Paris", + }, + { + text: "is", + }, + { + text: "the", + }, + { + text: "capital", + }, + { + text: "of", + }, + { + text: "France", + }, + ], + sources: ["Wikipedia"], + }, +}; function supportsDVH() { try { @@ -39,20 +65,6 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { Record<string, string[]> >({}); - // helper function to append a new msg - const appendToChatHistory = useCallback( - (role: "user" | "model", content: string) => { - setChatHistory((prev) => [ - ...prev, - { - role, - parts: [{ text: content }], - }, - ]); - }, - [], - ); - // This is the streamed AI response we get from the server. const [aiResponse, setAIResponse] = useState(""); @@ -112,10 +124,7 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { // Append to chat history in this way: // If the last message was from the model, append to that message // Otherwise, Start a new message from the model and append to that - if ( - chatHistory.length > 0 && - chatHistory[chatHistory.length - 1].role === "model" - ) { + if (chatHistory.length > 0) { setChatHistory((prev: any) => { const lastMessage = prev[prev.length - 1]; const newParts = [ @@ -124,17 +133,16 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { ]; return [ ...prev.slice(0, prev.length - 1), - { ...lastMessage, parts: newParts }, + { + ...lastMessage, + answer: { + parts: newParts, + sources: lastMessage.answer.sources, + }, + }, ]; }); } else { - setChatHistory((prev) => [ - ...prev, - { - role: "model", - parts: [{ text: parsedPart.response }], - }, - ]); } } } catch (error) { @@ -166,12 +174,16 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { const getSearchResults = async () => { setIsAiLoading(true); - console.log(value); + const _value = value.trim(); + setValue(""); - appendToChatHistory("user", value); + // @dhravya, this is using temporary dummy data remove this before testing + setChatHistory((prev) => [...prev, dummyChatHistory]); + setTimeout(() => setIsAiLoading(false), 5000); + return; const sourcesResponse = await fetch( - `/api/chat?sourcesOnly=true&q=${value}`, + `/api/chat?sourcesOnly=true&q=${_value}`, { method: "POST", body: JSON.stringify({ @@ -189,7 +201,7 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { ); // TODO: PASS THE `SPACE` TO THE API - const response = await fetch(`/api/chat?q=${value}`, { + const response = await fetch(`/api/chat?q=${_value}`, { method: "POST", body: JSON.stringify({ chatHistory, @@ -201,8 +213,19 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { return; } + setChatHistory((prev) => [ + ...prev, + { + question: _value, + answer: { + parts: [], + sources: sourcesInJson.ids ?? [], + }, + }, + ]); + if (response.body) { - let reader = response.body.getReader(); + let reader = response.body?.getReader(); let decoder = new TextDecoder("utf-8"); let result = ""; @@ -218,7 +241,7 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { handleStreamData(decoder.decode(value)); - return reader.read().then(processText); + return reader?.read().then(processText); }); } }; @@ -232,7 +255,15 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { <> <AnimatePresence mode="wait"> {layout === "chat" ? ( - <Chat key="chat" sidebarOpen={sidebarOpen} /> + <Chat + key="chat" + isLoading={isAiLoading} + chatHistory={chatHistory} + sidebarOpen={sidebarOpen} + askQuestion={onSend} + setValue={setValue} + value={value} + /> ) : ( <main data-sidebar-open={sidebarOpen} @@ -242,27 +273,6 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { hide ? "" : "main-hidden", )} > - <div className="flex w-full flex-col"> - {/* {chatHistory.map((chat, index) => ( - <ChatMessage - key={index} - message={chat.parts.map((part) => part.text).join("")} - user={chat.role === "model" ? "ai" : session?.user!} - /> - ))} */} - {searchResults.length > 0 && ( - <div className="mt-4"> - <h1>Related memories</h1> - <div className="grid gap-6"> - {searchResults.map((value, index) => ( - <Card key={index}> - <CardContent className="space-y-2">{value}</CardContent> - </Card> - ))} - </div> - </div> - )} - </div> <h1 className="text-rgray-11 mt-auto w-full text-center text-3xl md:mt-0"> Ask your Second brain </h1> @@ -316,9 +326,22 @@ export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) { ); } -export function Chat({ sidebarOpen }: { sidebarOpen: boolean }) { +export function Chat({ + sidebarOpen, + chatHistory, + isLoading = false, + askQuestion, + setValue, + value, +}: { + sidebarOpen: boolean; + isLoading?: boolean; + chatHistory: ChatHistory[]; + askQuestion: () => void; + setValue: (value: string) => void; + value: string; +}) { const textArea = useRef<HTMLDivElement>(null); - const [value, setValue] = useState(""); function onValueChange(e: React.ChangeEvent<HTMLTextAreaElement>) { const value = e.target.value; @@ -334,21 +357,20 @@ export function Chat({ sidebarOpen }: { sidebarOpen: boolean }) { "sidebar relative flex w-full flex-col items-end gap-5 px-5 pt-5 transition-[padding-left,padding-top,padding-right] delay-200 duration-200 md:items-center md:gap-10 md:px-72 [&[data-sidebar-open='true']]:pr-10 [&[data-sidebar-open='true']]:delay-0 md:[&[data-sidebar-open='true']]:pl-[calc(2.5rem+30vw)]", )} > - <div className="min-h-[100%] w-full px-5 pt-10"> - <ChatQuestion>who is dhravya</ChatQuestion> - <ChatAnswer> - Dhravya Shah is an 18-year-old full-stack developer based in Arizona, - USA. He is a passionate developer who focuses on creating products - that people love. Dhravya has a background in entrepreneurship, having - been a 2x acquired founder and a participant in various hackathons. He - is also involved in open-source contributions, content creation to - inspire others in coding, and has a growing community of developers. - Dhravya's work spans from creating AI-powered note-taking apps to - personalized music companions and educational tools. Additionally, he - is a guitarist, student, and active in sharing his experiences as a - developer and entrepreneur - </ChatAnswer> + <div className="scrollbar-none flex max-h-screen w-full flex-col overflow-y-auto px-5"> + {chatHistory.map((msg, i) => ( + <ChatMessage index={i} key={i} isLast={i === chatHistory.length - 1}> + <ChatQuestion>{msg.question}</ChatQuestion> + <ChatAnswer + loading={i === chatHistory.length - 1 ? isLoading : false} + sources={msg.answer.sources} + > + {msg.answer.parts.map((part) => part.text).join(" ")} + </ChatAnswer> + </ChatMessage> + ))} </div> + <div className="from-rgray-2 via-rgray-2 to-rgray-2/0 absolute bottom-0 left-0 h-[30%] w-full bg-gradient-to-t" /> <div data-sidebar-open={sidebarOpen} className="absolute flex w-full items-center justify-center" @@ -360,11 +382,11 @@ export function Chat({ sidebarOpen }: { sidebarOpen: boolean }) { }} side="top" align="start" - className="bg-white/5" + className="bg-[#252525]" /> <Textarea2 ref={textArea} - className="h-auto w-full flex-row items-start justify-center overflow-auto px-3 md:items-center md:justify-center" + className="bg-rgray-2 h-auto w-full flex-row items-start justify-center overflow-auto px-3 md:items-center md:justify-center" textAreaProps={{ placeholder: "Ask your SuperMemory...", className: @@ -373,11 +395,16 @@ export function Chat({ sidebarOpen }: { sidebarOpen: boolean }) { rows: 1, autoFocus: true, onChange: onValueChange, + onKeyDown: (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + askQuestion(); + } + }, }} > <div className="text-rgray-11/70 ml-auto mt-auto flex h-full w-min items-center justify-center pb-3 pr-2"> <button - type="submit" + onClick={askQuestion} disabled={value.trim().length < 1} className="text-rgray-11/70 bg-rgray-3 focus-visible:ring-rgray-8 hover:bg-rgray-4 mt-auto flex items-center justify-center rounded-full p-2 ring-2 ring-transparent transition-[filter] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" > diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 8f4392b9..5eca08cc 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -71,3 +71,7 @@ export function countLines(textarea: HTMLTextAreaElement): number { return 0; } + +export function convertRemToPixels(rem: number) { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); +} |