diff options
| author | Dhravya Shah <[email protected]> | 2024-07-25 17:35:15 -0500 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2024-07-25 17:35:15 -0500 |
| commit | c57719446ae95c2bbd432d7b2b6648a23b35c351 (patch) | |
| tree | c6f7aca777c7f4748cc6dc335fe56fba8725af02 /apps/web | |
| parent | add try catch in api/add for better error handling (diff) | |
| parent | ughh, regenerated migrations. my bad. (diff) | |
| download | supermemory-kush/experimental-thread.tar.xz supermemory-kush/experimental-thread.zip | |
solve merge conflictskush/experimental-thread
Diffstat (limited to 'apps/web')
32 files changed, 975 insertions, 329 deletions
diff --git a/apps/web/app/(auth)/onboarding/page.tsx b/apps/web/app/(auth)/onboarding/page.tsx index 1912a8e8..10dd2325 100644 --- a/apps/web/app/(auth)/onboarding/page.tsx +++ b/apps/web/app/(auth)/onboarding/page.tsx @@ -1,17 +1,16 @@ "use client"; -import Link from "next/link"; import { + ArrowUturnDownIcon, ChevronLeftIcon, ChevronRightIcon, QuestionMarkCircleIcon, - ArrowTurnDownLeftIcon, } from "@heroicons/react/24/solid"; import { CheckIcon, PlusCircleIcon } from "@heroicons/react/24/outline"; import { motion } from "framer-motion"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { createMemory } from "@repo/web/app/actions/doers"; +import { completeOnboarding, createMemory } from "@repo/web/app/actions/doers"; import { useRouter } from "next/navigation"; import Logo from "../../../public/logo.svg"; import Image from "next/image"; @@ -20,6 +19,18 @@ import gradientStyle from "../signin/_components/TextGradient/gradient.module.cs export default function Home() { const [currStep, setCurrStep] = useState(0); + const { push } = useRouter(); + + useEffect(() => { + const updateDb = async () => { + await completeOnboarding(); + }; + if (currStep > 3) { + updateDb().then(() => { + push("/home?q=what%20is%20supermemory"); + }); + } + }, [currStep]); return ( <main className="min-h-screen text-sm text-[#B8C4C6] font-geistSans"> @@ -175,7 +186,7 @@ function StepIndicator({ /> <p>Step: {currStep}/3</p> <ChevronRightIcon - className="h-6" + className="h-6 cursor-pointer" onClick={() => currStep <= 3 && setCurrStep(currStep + 1)} /> </div> @@ -300,7 +311,7 @@ function StepThree({ currStep }: { currStep: number }) { type="submit" className="rounded-lg bg-[#369DFD1A] p-3 absolute bottom-4 right-2" > - <ArrowTurnDownLeftIcon className="w-4 h-4 text-[#369DFD]" /> + <ArrowUturnDownIcon className="w-4 h-4 text-[#369DFD]" /> </button> </form> </li> @@ -374,6 +385,12 @@ function StepTwo({ currStep }: { currStep: number }) { } function Navbar() { + const router = useRouter(); + const handleSkip = async () => { + await completeOnboarding(); + router.push("/home?q=what%20is%20supermemory"); + }; + return ( <div className="flex items-center justify-between p-4 fixed top-0 left-0 w-full"> <Image @@ -382,9 +399,9 @@ function Navbar() { className="hover:brightness-125 duration-200 size-12" /> - <Link href="/home"> - <button className="text-sm">Skip</button> - </Link> + <button className="text-sm" onClick={handleSkip}> + Skip + </button> </div> ); } diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx index a31343cd..3b563b90 100644 --- a/apps/web/app/(auth)/signin/page.tsx +++ b/apps/web/app/(auth)/signin/page.tsx @@ -18,7 +18,7 @@ async function Signin({ const user = await auth(); if (user) { - await redirect("/home"); + redirect("/home"); } return ( @@ -64,7 +64,7 @@ async function Signin({ action={async () => { "use server"; await signIn("google", { - redirectTo: "/home?firstTime=true", + redirectTo: "/home", }); }} > diff --git a/apps/web/app/(dash)/chat/[chatid]/loading.tsx b/apps/web/app/(dash)/chat/[chatid]/loading.tsx index d28961a6..422adb8e 100644 --- a/apps/web/app/(dash)/chat/[chatid]/loading.tsx +++ b/apps/web/app/(dash)/chat/[chatid]/loading.tsx @@ -7,9 +7,15 @@ async function Page({ }: { searchParams: Record<string, string | string[] | undefined>; }) { - const q = (searchParams?.q as string) ?? "from_loading"; + const q = (searchParams?.q as string) ?? ""; return ( - <ChatWindow q={q} spaces={[]} initialChat={undefined} threadId={"idk"} /> + <ChatWindow + proMode={false} + q={q} + spaces={[]} + initialChat={undefined} + threadId={"idk"} + /> ); } diff --git a/apps/web/app/(dash)/chat/[chatid]/page.tsx b/apps/web/app/(dash)/chat/[chatid]/page.tsx index 87fd0b19..29ffb3d8 100644 --- a/apps/web/app/(dash)/chat/[chatid]/page.tsx +++ b/apps/web/app/(dash)/chat/[chatid]/page.tsx @@ -9,7 +9,8 @@ async function Page({ params: { chatid: string }; searchParams: Record<string, string | string[] | undefined>; }) { - const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams); + const { firstTime, q, spaces, proMode } = + chatSearchParamsCache.parse(searchParams); let chat: Awaited<ReturnType<typeof getFullChatThread>>; @@ -31,6 +32,7 @@ async function Page({ spaces={spaces ?? []} initialChat={chat.data.length > 0 ? chat.data : undefined} threadId={params.chatid} + proMode={proMode} /> ); } diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index 28b99c9d..ed65bf7a 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -35,14 +35,19 @@ function ChatWindow({ parts: [], sources: [], }, + proModeProcessing: { + queries: [], + }, }, ], threadId, + proMode, }: { q: string; spaces: { id: number; name: string }[]; initialChat?: ChatHistory[]; threadId: string; + proMode: boolean; }) { const [layout, setLayout] = useState<"chat" | "initial">("chat"); const [chatHistory, setChatHistory] = useState<ChatHistory[]>(initialChat); @@ -63,13 +68,17 @@ function ChatWindow({ const router = useRouter(); - const getAnswer = async (query: string, spaces: string[]) => { + const getAnswer = async ( + query: string, + spaces: string[], + proMode: boolean = false, + ) => { if (query.trim() === "from_loading" || query.trim().length === 0) { return; } const sourcesFetch = await fetch( - `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true&threadId=${threadId}`, + `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true&threadId=${threadId}&proMode=${proMode}`, { method: "POST", body: JSON.stringify({ chatHistory }), @@ -91,6 +100,8 @@ function ChatWindow({ behavior: "smooth", }); + let proModeListedQueries: string[] = []; + const updateChatHistoryAndFetch = async () => { // Step 1: Update chat history with the assistant's response await new Promise((resolve) => { @@ -123,6 +134,11 @@ function ChatWindow({ ).length, })); + lastAnswer.proModeProcessing.queries = + sourcesParsed.data.proModeListedQueries ?? []; + + proModeListedQueries = lastAnswer.proModeProcessing.queries; + resolve(newChatHistory); return newChatHistory; }); @@ -130,7 +146,7 @@ function ChatWindow({ // Step 2: Fetch data from the API const resp = await fetch( - `/api/chat?q=${query}&spaces=${spaces}&threadId=${threadId}`, + `/api/chat?q=${(query += proModeListedQueries.join(" "))}&spaces=${spaces}&threadId=${threadId}`, { method: "POST", body: JSON.stringify({ chatHistory, sources: sourcesParsed.data }), @@ -181,6 +197,7 @@ function ChatWindow({ getAnswer( q, spaces.map((s) => `${s.id}`), + proMode, ); } } else { @@ -224,6 +241,28 @@ function ChatWindow({ {chat.question} </h2> + {chat.proModeProcessing?.queries?.length > 0 && ( + <div className="flex flex-col mt-2"> + <div className="text-foreground-menu py-2"> + Pro Mode + </div> + <div className="text-base"> + <div className="flex gap-2 text-base"> + {chat.proModeProcessing.queries.map( + (query, idx) => ( + <div + className="bg-secondary rounded-md p-2" + key={`promode-query-${idx}`} + > + {query} + </div> + ), + )} + </div> + </div> + </div> + )} + <div className="flex flex-col mt-2"> <div> <div className="text-foreground-menu py-2">Answer</div> @@ -407,6 +446,9 @@ function ChatWindow({ parts: [], sources: [], }, + proModeProcessing: { + queries: [], + }, }, ]; }); diff --git a/apps/web/app/(dash)/chat/route.ts b/apps/web/app/(dash)/chat/route.ts new file mode 100644 index 00000000..94f250ff --- /dev/null +++ b/apps/web/app/(dash)/chat/route.ts @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export async function GET() { + return redirect("/home"); +} diff --git a/apps/web/app/(dash)/dialogContentContainer.tsx b/apps/web/app/(dash)/dialogContentContainer.tsx index aae71237..4e8d81ef 100644 --- a/apps/web/app/(dash)/dialogContentContainer.tsx +++ b/apps/web/app/(dash)/dialogContentContainer.tsx @@ -100,7 +100,7 @@ export function DialogContentContainer({ }, []); return ( - <DialogContent className="sm:max-w-[475px] text-[#F2F3F5] rounded-2xl bg-background z-[39] backdrop-blur-md"> + <DialogContent className="sm:max-w-[475px] text-[#F2F3F5] rounded-2xl bg-background z-[39]"> <form action={async (e: FormData) => { const content = e.get("content")?.toString(); diff --git a/apps/web/app/(dash)/header/autoBreadCrumbs.tsx b/apps/web/app/(dash)/header/autoBreadCrumbs.tsx index a823671c..671464ff 100644 --- a/apps/web/app/(dash)/header/autoBreadCrumbs.tsx +++ b/apps/web/app/(dash)/header/autoBreadCrumbs.tsx @@ -13,8 +13,6 @@ import React from "react"; function AutoBreadCrumbs() { const pathname = usePathname(); - console.log(pathname.split("/").filter(Boolean)); - return ( <Breadcrumb className="hidden md:block"> <BreadcrumbList> @@ -31,7 +29,7 @@ function AutoBreadCrumbs() { .filter(Boolean) .map((path, idx, paths) => ( <> - <BreadcrumbItem key={path}> + <BreadcrumbItem key={path + idx}> <BreadcrumbLink href={`/${paths.slice(0, idx + 1).join("/")}`}> {path.charAt(0).toUpperCase() + path.slice(1)} </BreadcrumbLink> diff --git a/apps/web/app/(dash)/header/header.tsx b/apps/web/app/(dash)/header/header.tsx index b9d400c9..eaade258 100644 --- a/apps/web/app/(dash)/header/header.tsx +++ b/apps/web/app/(dash)/header/header.tsx @@ -6,6 +6,14 @@ import Logo from "../../../public/logo.svg"; import { getChatHistory } from "../../actions/fetchers"; import NewChatButton from "./newChatButton"; import AutoBreadCrumbs from "./autoBreadCrumbs"; +import SignOutButton from "./signOutButton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@repo/ui/shadcn/dropdown-menu"; +import { CaretDownIcon } from "@radix-ui/react-icons"; async function Header() { const chatThreads = await getChatHistory(); @@ -32,26 +40,28 @@ async function Header() { <div className="flex items-center gap-2"> <NewChatButton /> - <div className="relative group"> - <button className="flex duration-200 items-center text-[#7D8994] hover:bg-[#1F2429] text-[13px] gap-2 px-3 py-2 rounded-xl"> + <DropdownMenu> + <DropdownMenuTrigger className="inline-flex flex-row flex-nowrap items-center text-muted-foreground hover:text-foreground"> History - </button> - - <div className="absolute p-4 hidden group-hover:block right-0 w-full md:w-[400px] max-h-[70vh] overflow-auto"> - <div className="bg-[#1F2429] rounded-xl p-2 flex flex-col shadow-lg"> - {chatThreads.data.map((thread) => ( + <CaretDownIcon /> + </DropdownMenuTrigger> + <DropdownMenuContent className="p-4 w-full md:w-[400px] max-h-[70vh] overflow-auto border-none"> + {chatThreads.data.map((thread) => ( + <DropdownMenuItem asChild> <Link prefetch={false} href={`/chat/${thread.id}`} key={thread.id} - className="p-2 rounded-md hover:bg-secondary" + className="p-2 rounded-md cursor-pointer focus:bg-secondary focus:text-current" > {thread.firstMessage} </Link> - ))} - </div> - </div> - </div> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + + <SignOutButton /> </div> </div> </div> diff --git a/apps/web/app/(dash)/header/signOutButton.tsx b/apps/web/app/(dash)/header/signOutButton.tsx new file mode 100644 index 00000000..4c61c74d --- /dev/null +++ b/apps/web/app/(dash)/header/signOutButton.tsx @@ -0,0 +1,22 @@ +import { signOut } from "@/server/auth"; +import { Button } from "@repo/ui/shadcn/button"; + +export default function SignOutButton() { + return ( + <form + action={async () => { + "use server"; + await signOut(); + }} + > + <Button + variant="ghost" + size="sm" + type="submit" + className="text-[#7D8994]" + > + Sign Out + </Button> + </form> + ); +} diff --git a/apps/web/app/(dash)/home/history.tsx b/apps/web/app/(dash)/home/history.tsx index 3d8d5a28..a4cd11d0 100644 --- a/apps/web/app/(dash)/home/history.tsx +++ b/apps/web/app/(dash)/home/history.tsx @@ -1,53 +1,65 @@ -import { getChatHistory } from '@repo/web/app/actions/fetchers'; -import { ArrowLongRightIcon } from '@heroicons/react/24/outline'; -import { Skeleton } from '@repo/ui/shadcn/skeleton'; -import Link from 'next/link'; -import { memo, useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; -import { chatThreads } from '@/server/db/schema'; +import { ArrowLongRightIcon } from "@heroicons/react/24/outline"; +import { Skeleton } from "@repo/ui/shadcn/skeleton"; +import { memo, useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { getQuerySuggestions } from "@/app/actions/doers"; -const History = memo(() => { - const [chatThreads_, setChatThreads] = useState< - (typeof chatThreads.$inferSelect)[] | null - >(null); +const History = memo(({ setQuery }: { setQuery: (q: string) => void }) => { + const [suggestions, setSuggestions] = useState<string[] | null>(null); - useEffect(() => { - (async () => { - const chatThreads = await getChatHistory(); - if (!chatThreads.success || !chatThreads.data) { - console.error(chatThreads.error); - return; - } - setChatThreads(chatThreads.data.reverse().slice(0, 3)); - })(); - }, []); + useEffect(() => { + (async () => { + const suggestions = await getQuerySuggestions(); + if (!suggestions.success || !suggestions.data) { + console.error(suggestions.error); + setSuggestions([]); + return; + } + console.log(suggestions); + if (typeof suggestions.data === "string") { + const queries = suggestions.data.slice(1, -1).split(", "); + const parsedQueries = queries.map((query) => + query.replace(/^'|'$/g, ""), + ); + console.log(parsedQueries); + setSuggestions(parsedQueries); + return; + } + setSuggestions(suggestions.data.reverse().slice(0, 3)); + })(); + }, []); - if (!chatThreads) { - return ( - <> - <Skeleton className="w-[80%] h-4 bg-[#3b444b] "></Skeleton> - <Skeleton className="w-[40%] h-4 bg-[#3b444b] "></Skeleton> - <Skeleton className="w-[60%] h-4 bg-[#3b444b] "></Skeleton> - </> - ); - } - - return ( - <ul className="text-base list-none space-y-3 text-[#b9b9b9] mt-8"> - {chatThreads_?.map((thread) => ( - <motion.li - initial={{ opacity: 0, filter: 'blur(1px)' }} - animate={{ opacity: 1, filter: 'blur(0px)' }} - className="flex items-center gap-2 truncate" - > - <ArrowLongRightIcon className="h-5" />{' '} - <Link prefetch={false} href={`/chat/${thread.id}`}> - {thread.firstMessage} - </Link> - </motion.li> - ))} - </ul> - ); + return ( + <ul className="text-base list-none space-y-3 text-[#b9b9b9] mt-8"> + {!suggestions && ( + <> + <Skeleton + key="loader-1" + className="w-[80%] h-4 bg-[#3b444b] " + ></Skeleton> + <Skeleton + key="loader-2" + className="w-[40%] h-4 bg-[#3b444b] " + ></Skeleton> + <Skeleton + key="loader-3" + className="w-[60%] h-4 bg-[#3b444b] " + ></Skeleton> + </> + )} + {suggestions?.map((suggestion) => ( + <motion.li + initial={{ opacity: 0, filter: "blur(1px)" }} + animate={{ opacity: 1, filter: "blur(0px)" }} + className="flex items-center gap-2 truncate cursor-pointer" + key={suggestion} + onClick={() => setQuery(suggestion)} + > + <ArrowLongRightIcon className="h-5" /> {suggestion} + </motion.li> + ))} + </ul> + ); }); export default History; diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index cc1856b4..d192d07d 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -4,12 +4,15 @@ import React, { useEffect, useState } from "react"; import QueryInput from "./queryinput"; import { getSessionAuthToken, getSpaces } from "@/app/actions/fetchers"; import { redirect, useRouter } from "next/navigation"; -import { createChatThread, linkTelegramToUser } from "@/app/actions/doers"; +import { + createChatThread, + getQuerySuggestions, + linkTelegramToUser, +} from "@/app/actions/doers"; import { toast } from "sonner"; import { motion } from "framer-motion"; -import { ChromeIcon, GithubIcon, TwitterIcon } from "lucide-react"; +import { ChromeIcon, GithubIcon, MailIcon, TwitterIcon } from "lucide-react"; import Link from "next/link"; -import { homeSearchParamsCache } from "@/lib/searchParams"; import History from "./history"; const slap = { @@ -26,28 +29,14 @@ const slap = { }; function Page({ searchParams }: { searchParams: Record<string, string> }) { - // TODO: use this to show a welcome page/modal - const firstTime = searchParams.firstTime === "true"; + const telegramUser = searchParams.telegramUser; + const extensionInstalled = searchParams.extension; + const [query, setQuery] = useState(searchParams.q || ""); - const query = searchParams.q || ""; - - if (firstTime) { - redirect("/onboarding"); - } - - const [queryPresent, setQueryPresent] = useState<boolean>(false); - - const [telegramUser, setTelegramUser] = useState<string | undefined>( - searchParams.telegramUser as string, - ); - const [extensionInstalled, setExtensionInstalled] = useState< - string | undefined - >(searchParams.extension as string); + const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); const { push } = useRouter(); - const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); - useEffect(() => { if (telegramUser) { const linkTelegram = async () => { @@ -63,10 +52,6 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) { linkTelegram(); } - if (extensionInstalled) { - toast.success("Extension installed successfully"); - } - getSpaces().then((res) => { if (res.success && res.data) { setSpaces(res.data); @@ -77,21 +62,21 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) { getSessionAuthToken().then((token) => { if (typeof window === "undefined") return; + if (extensionInstalled) { + toast.success("Extension installed successfully"); + } window.postMessage({ token: token.data }, "*"); }); }, [telegramUser]); return ( <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col px-2 md:px-0"> - {/* all content goes here */} - {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} - <motion.h1 {...{ ...slap, transition: { ...slap.transition, delay: 0.2 }, }} - className="text-center mx-auto bg-[linear-gradient(180deg,_#FFF_0%,_rgba(255,_255,_255,_0.00)_202.08%)] bg-clip-text text-4xl tracking-tighter text-transparent md:text-5xl" + className="text-center mx-auto bg-[linear-gradient(180deg,_#FFF_0%,_rgba(255,_255,_255,_0.00)_202.08%)] bg-clip-text text-4xl tracking-tighter text-transparent md:text-5xl pb-2" > <span>Ask your</span>{" "} <span className="inline-flex items-center gap-2 bg-gradient-to-r to-blue-300 from-zinc-300 text-transparent bg-clip-text"> @@ -99,17 +84,16 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) { </span> </motion.h1> - <div className="w-full pb-20 mt-12"> + <div className="w-full pb-20 mt-10"> <QueryInput - initialQuery={query} - setQueryPresent={setQueryPresent} - handleSubmit={async (q, spaces) => { + query={query} + setQuery={setQuery} + handleSubmit={async (q, spaces, proMode) => { if (q.length === 0) { toast.error("Query is required"); return; } - console.log("creating thread"); const threadid = await createChatThread(q); if (!threadid.success || !threadid.data) { @@ -117,15 +101,14 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) { return; } - console.log("pushing to chat"); push( - `/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}`, + `/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}&proMode=${proMode}`, ); }} initialSpaces={spaces} /> - <History /> + <History setQuery={setQuery} /> </div> <div className="w-full fixed bottom-0 left-0 p-4"> @@ -140,12 +123,12 @@ function Page({ searchParams }: { searchParams: Record<string, string> }) { Install extension </Link> <Link - href="https://github.com/supermemoryai/supermemory/issues/new" + href="mailto:[email protected]" target="_blank" rel="noreferrer" className="flex items-center gap-2 text-muted-foreground hover:text-grey-50 duration-300" > - <GithubIcon className="w-4 h-4" /> + <MailIcon className="w-4 h-4" /> Bug report </Link> <Link diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index e49f06e0..82561438 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -1,26 +1,32 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { FilterSpaces } from "./filterSpaces"; import { ArrowRightIcon } from "@repo/ui/icons"; import Image from "next/image"; +import { Switch } from "@repo/ui/shadcn/switch"; +import { Label } from "@repo/ui/shadcn/label"; function QueryInput({ - setQueryPresent, - initialQuery, initialSpaces, handleSubmit, + query, + setQuery, }: { - setQueryPresent: (t: boolean) => void; initialSpaces?: { id: number; name: string; }[]; - initialQuery?: string; mini?: boolean; - handleSubmit: (q: string, spaces: { id: number; name: string }[]) => void; + handleSubmit: ( + q: string, + spaces: { id: number; name: string }[], + proMode: boolean, + ) => void; + query: string; + setQuery: (q: string) => void; }) { - const [q, setQ] = useState(initialQuery || ""); + const [proMode, setProMode] = useState(false); const [selectedSpaces, setSelectedSpaces] = useState< { id: number; name: string }[] @@ -34,11 +40,11 @@ function QueryInput({ {/* input and action button */} <form action={async () => { - if (q.trim().length === 0) { + if (query.trim().length === 0) { return; } - handleSubmit(q, selectedSpaces); - setQ(""); + handleSubmit(query, selectedSpaces, proMode); + setQuery(""); }} > <textarea @@ -51,20 +57,15 @@ function QueryInput({ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (q.trim().length === 0) { + if (query.trim().length === 0) { return; } - handleSubmit(q, selectedSpaces); - setQ(""); + handleSubmit(query, selectedSpaces, proMode); + setQuery(""); } }} - onChange={(e) => - setQ((prev) => { - setQueryPresent(!!e.target.value.length); - return e.target.value; - }) - } - value={q} + onChange={(e) => setQuery(e.target.value)} + value={query} /> <div className="flex p-2 px-3 w-full items-center justify-between rounded-xl overflow-hidden"> <FilterSpaces @@ -72,9 +73,22 @@ function QueryInput({ setSelectedSpaces={setSelectedSpaces} initialSpaces={initialSpaces || []} /> - <button type="submit" className="rounded-lg bg-[#369DFD1A] p-3"> - <Image src={ArrowRightIcon} alt="Enter" /> - </button> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Label htmlFor="pro-mode" className="text-sm text-[#9B9B9B]"> + Pro mode + </Label> + <Switch + value={proMode ? "on" : "off"} + onCheckedChange={(v) => setProMode(v)} + id="pro-mode" + about="Pro mode" + /> + </div> + <button type="submit" className="rounded-lg bg-[#369DFD1A] p-3"> + <Image src={ArrowRightIcon} alt="Enter" /> + </button> + </div> </div> </form> </div> diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index b2b27a4f..c6174945 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { auth } from "../../server/auth"; import { Toaster } from "@repo/ui/shadcn/sonner"; import BackgroundPlus from "../(landing)/GridPatterns/PlusGrid"; +import { getUser } from "../actions/fetchers"; async function Layout({ children }: { children: React.ReactNode }) { const info = await auth(); @@ -12,6 +13,13 @@ async function Layout({ children }: { children: React.ReactNode }) { return redirect("/signin"); } + const user = await getUser(); + const hasOnboarded = user.data?.hasOnboarded; + + if (!hasOnboarded) { + redirect("/onboarding"); + } + return ( <main className="h-screen flex flex-col"> <div className="fixed top-0 left-0 w-full z-40"> diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index 0b487e61..c56a3247 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,176 +1,365 @@ -import React from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { MemoriesIcon, CanvasIcon, AddIcon } from '@repo/ui/icons'; -import { DialogTrigger } from '@repo/ui/shadcn/dialog'; +"use client"; -import { HomeIcon } from '@heroicons/react/24/solid'; +import React, { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; import { - PencilSquareIcon, - PlusIcon, - PresentationChartLineIcon, - RectangleStackIcon, -} from '@heroicons/react/24/solid'; -import DialogTriggerWrapper, { - DialogDesktopTrigger, - DialogMobileTrigger, -} from './dialogTriggerWrapper'; - -const menuItems = [ - { - icon: MemoriesIcon, - text: 'Memories', - url: '/memories', - disabled: false, - }, - { - icon: CanvasIcon, - text: 'Canvas', - url: '/canvas', - disabled: true, - }, -]; - -const items = [ - { - icon: <HomeIcon className="h-6 w-6" />, - name: 'home', - url: '/home', - disabled: false, - }, - { - icon: <RectangleStackIcon className="h-6 w-6" />, - name: 'memories', - url: '/memories', - disabled: false, - }, - { - icon: <PencilSquareIcon className="h-6 w-6" />, - name: 'editor', - url: '/#', - disabled: true, - }, - { - icon: <PresentationChartLineIcon className="h-6 w-6" />, - name: 'thinkpad', - url: '/#', - disabled: true, - }, -]; + MemoriesIcon, + ExploreIcon, + CanvasIcon, + AddIcon, + HomeIcon as HomeIconWeb, +} from "@repo/ui/icons"; +import { Button } from "@repo/ui/shadcn/button"; +import { MinusIcon, PlusCircleIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@repo/ui/shadcn/dialog"; +import { Label } from "@repo/ui/shadcn/label"; +import { Textarea } from "@repo/ui/shadcn/textarea"; +import { toast } from "sonner"; +import { getSpaces } from "../actions/fetchers"; +import { HomeIcon } from "@heroicons/react/24/solid"; +import { createMemory, createSpace } from "../actions/doers"; +import ComboboxWithCreate from "@repo/ui/shadcn/combobox"; +import { StoredSpace } from "@/server/db/schema"; +import useMeasure from "react-use-measure"; function Menu() { - return ( - <> - {/* Desktop Menu */} - <div className="hidden lg:flex items-center pointer-events-none z-[39] fixed left-2 top-0 h-screen flex-col justify-center px-2"> - <div className="pointer-events-none z-10 absolute top-1/2 h-1/3 w-full -translate-y-1/2 bg-secondary blur-[300px] "></div> - <div className="pointer-events-auto flex flex-col gap-2"> - <DialogDesktopTrigger /> - <div className="inline-flex w-14 flex-col items-start gap-6 rounded-2xl border-[1px] border-gray-700/50 bg-secondary px-3 py-4 text-[#b9b9b9] shadow-md shadow-[#1d1d1dc7]"> - {items.map((v) => ( - <NavItem {...v} /> - ))} - </div> - </div> - </div> - - {/* Mobile Menu */} - <div className="lg:hidden fixed bottom-0 left-0 w-full p-4 bg-secondary z-50 border-t-2 border-border"> - <div className="flex justify-around items-center"> - <Link - href={'/'} - className={`flex flex-col items-center text-white ${'cursor-pointer'}`} - > - <HomeIcon width={24} height={24} /> - <p className="text-xs text-foreground-menu mt-2">Home</p> - </Link> - - <DialogMobileTrigger /> - {menuItems.map((item) => ( - <Link - aria-disabled={item.disabled} - href={item.disabled ? '#' : item.url} - key={item.url} - className={`flex flex-col items-center ${ - item.disabled - ? 'opacity-50 pointer-events-none' - : 'cursor-pointer' - }`} - > - <Image - src={item.icon} - alt={`${item.text} icon`} - width={24} - height={24} - /> - <p className="text-xs text-foreground-menu mt-2">{item.text}</p> - </Link> - ))} - </div> - </div> - </> - ); -} + const [spaces, setSpaces] = useState<StoredSpace[]>([]); -export function Navbar() { - return ( - <div className="pointer-events-none fixed left-0 top-0 flex h-screen flex-col justify-center px-2"> - <div className="pointer-events-none absolute top-1/2 h-1/3 w-full -translate-y-1/2 bg-blue-500/20 blur-[300px] "></div> - <div className="pointer-events-auto"> - <div className="inline-flex w-14 flex-col items-start gap-6 rounded-2xl border-2 border-border px-3 py-4 text-[#b9b9b9] shadow-md shadow-[#1d1d1dc7]"> - <Top /> - {items.map((v) => ( - <NavItem {...v} /> - ))} - </div> - </div> - </div> - ); -} + useEffect(() => { + (async () => { + let spaces = await getSpaces(); -function Top() { - return ( - <DialogTriggerWrapper> - <DialogTrigger> - <div className="space-y-4 group relative"> - <div className="cursor-pointer px-1 hover:scale-105 hover:text-[#bfc4c9] active:scale-90"> - <PlusIcon className="h-6 w-6" /> - </div> - <div className="h-[1px] w-full bg-[#323b41]"></div> - <div className="opacity-0 group-hover:opacity-100 scale-x-50 group-hover:scale-x-100 origin-left transition-all absolute whitespace-nowrap -top-1 -translate-y-1/2 left-[150%] pointer-events-none border-gray-700/50 border-[1px] bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-2xl px-2 py-1"> - Add Memories - </div> - </div> - </DialogTrigger> - </DialogTriggerWrapper> - ); -} + if (!spaces.success || !spaces.data) { + toast.warning("Unable to get spaces", { + richColors: true, + }); + setSpaces([]); + return; + } + setSpaces(spaces.data); + })(); + }, []); + + const menuItems = [ + { + icon: HomeIconWeb, + text: "Home", + url: "/home", + disabled: false, + }, + { + icon: MemoriesIcon, + text: "Memories", + url: "/memories", + disabled: false, + }, + ]; + + const [content, setContent] = useState(""); + const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]); + + const autoDetectedType = useMemo(() => { + if (content.length === 0) { + return "none"; + } + + if ( + content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/) + ) { + return "tweet"; + } else if (content.match(/https?:\/\/[\w\.]+/)) { + return "page"; + } else if (content.match(/https?:\/\/www\.[\w\.]+/)) { + return "page"; + } else { + return "note"; + } + }, [content]); + + const [dialogOpen, setDialogOpen] = useState(false); + + const options = useMemo( + () => + spaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + })), + [spaces], + ); + + const handleSubmit = async (content?: string, spaces?: number[]) => { + setDialogOpen(false); + + toast.info("Creating memory...", { + icon: <PlusCircleIcon className="w-4 h-4 text-white animate-spin" />, + duration: 7500, + }); + + if (!content || content.length === 0) { + toast.error("Content is required"); + return; + } + + console.log(spaces); + + const cont = await createMemory({ + content: content, + spaces: spaces ?? undefined, + }); + + setContent(""); + setSelectedSpaces([]); + + if (cont.success) { + toast.success("Memory created", { + richColors: true, + }); + } else { + toast.error(`Memory creation failed: ${cont.error}`); + } + }; + + return ( + <> + {/* Desktop Menu */} + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <div className="hidden lg:flex fixed h-screen pb-20 w-full p-4 items-center justify-start top-0 left-0 pointer-events-none z-[39]"> + <div className="pointer-events-auto group flex w-14 text-foreground-menu text-[15px] font-medium flex-col items-start gap-6 overflow-hidden rounded-[28px] border-2 border-border bg-secondary px-3 py-4 duration-200 hover:w-40 z-[99999]"> + <div className="border-b border-border pb-4 w-full"> + <DialogTrigger + className={`flex w-full text-white brightness-75 hover:brightness-125 focus:brightness-125 cursor-pointer items-center gap-3 px-1 duration-200 justify-start`} + > + <Image + src={AddIcon} + alt="Logo" + width={24} + height={24} + className="hover:brightness-125 focus:brightness-125 duration-200 text-white" + /> + <p className="opacity-0 duration-200 group-hover:opacity-100"> + Add + </p> + </DialogTrigger> + </div> + {menuItems.map((item) => ( + <Link + aria-disabled={item.disabled} + href={item.disabled ? "#" : item.url} + key={item.url} + className={`flex w-full ${ + item.disabled + ? "cursor-not-allowed opacity-30" + : "text-white brightness-75 hover:brightness-125 cursor-pointer" + } items-center gap-3 px-1 duration-200 hover:scale-105 active:scale-90 justify-start`} + > + <Image + src={item.icon} + alt={`${item.text} icon`} + width={24} + height={24} + className="hover:brightness-125 duration-200" + /> + <p className="opacity-0 duration-200 group-hover:opacity-100"> + {item.text} + </p> + </Link> + ))} + </div> + </div> + + <DialogContent className="sm:max-w-[475px] text-[#F2F3F5] rounded-2xl bg-background z-[39]"> + <form + action={async (e: FormData) => { + const content = e.get("content")?.toString(); + + await handleSubmit(content, selectedSpaces); + }} + className="flex flex-col gap-4 " + > + <DialogHeader> + <DialogTitle>Add memory</DialogTitle> + <DialogDescription className="text-[#F2F3F5]"> + A "Memory" is a bookmark, something you want to remember. + </DialogDescription> + </DialogHeader> + + <div> + <Label htmlFor="name">Resource (URL or content)</Label> + <Textarea + className={`bg-[#2F353C] text-[#DBDEE1] max-h-[35vh] overflow-auto focus-visible:ring-0 border-none focus-visible:ring-offset-0 mt-2 ${/^https?:\/\/\S+$/i.test(content) && "text-[#1D9BF0] underline underline-offset-2"}`} + id="content" + name="content" + rows={8} + placeholder="Start typing a note or paste a URL here. I'll remember it." + value={content} + onChange={(e) => setContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(content, selectedSpaces); + } + }} + /> + </div> + + <div> + <Label className="space-y-1" htmlFor="space"> + <h3 className="font-semibold text-lg tracking-tight"> + Spaces (Optional) + </h3> + <p className="leading-normal text-[#F2F3F5] text-sm"> + A space is a collection of memories. It's a way to organise + your memories. + </p> + </Label> + + <ComboboxWithCreate + options={spaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + }))} + onSelect={(v) => + setSelectedSpaces((prev) => { + if (v === "") { + return []; + } + return [...prev, parseInt(v)]; + }) + } + onSubmit={async (spaceName) => { + const space = options.find((x) => x.label === spaceName); + toast.info("Creating space..."); + + if (space) { + toast.error("A space with that name already exists."); + } + + const creationTask = await createSpace(spaceName); + if (creationTask.success && creationTask.data) { + toast.success("Space created " + creationTask.data); + setSpaces((prev) => [ + ...prev, + { + name: spaceName, + id: creationTask.data!, + createdAt: new Date(), + user: null, + numItems: 0, + }, + ]); + setSelectedSpaces((prev) => [...prev, creationTask.data!]); + } else { + toast.error( + "Space creation failed: " + creationTask.error ?? + "Unknown error", + ); + } + }} + placeholder="Select or create a new space." + className="bg-[#2F353C] h-min rounded-md mt-4 mb-4" + /> + + <div> + {selectedSpaces.length > 0 && ( + <div className="flex flex-row flex-wrap gap-0.5 h-min"> + {[...new Set(selectedSpaces)].map((x, idx) => ( + <button + key={x} + type="button" + onClick={() => + setSelectedSpaces((prev) => + prev.filter((y) => y !== x), + ) + } + className={`relative group p-2 py-3 bg-[#3C464D] max-w-32 ${ + idx === selectedSpaces.length - 1 + ? "rounded-br-xl" + : "" + }`} + > + <p className="line-clamp-1"> + {spaces.find((y) => y.id === x)?.name} + </p> + <div className="absolute h-full right-0 top-0 p-1 opacity-0 group-hover:opacity-100 items-center"> + <MinusIcon className="w-6 h-6 rounded-full bg-secondary" /> + </div> + </button> + ))} + </div> + )} + </div> + </div> + + <DialogFooter> + <Button + disabled={autoDetectedType === "none"} + variant={"secondary"} + type="submit" + > + Save {autoDetectedType != "none" && autoDetectedType} + </Button> + </DialogFooter> + </form> + </DialogContent> + + {/* Mobile Menu */} + <div className="lg:hidden fixed bottom-0 left-0 w-full p-4 bg-secondary z-50 border-t-2 border-border"> + <div className="flex justify-around items-center"> + <Link + href={"/"} + className={`flex flex-col items-center text-white ${"cursor-pointer"}`} + > + <HomeIcon width={24} height={24} /> + <p className="text-xs text-foreground-menu mt-2">Home</p> + </Link> -function NavItem({ - disabled, - icon, - url, - name, -}: { - disabled: boolean; - icon: React.JSX.Element; - name: string; - url: string; -}) { - return ( - <div className="relative group"> - <Link aria-disabled={disabled} href={disabled ? '#' : url}> - <div - className={`cursor-pointer px-1 hover:scale-105 hover:text-[#bfc4c9] active:scale-90 ${disabled && 'opacity-50'}`} - > - {icon} - </div> - </Link> - <div className="opacity-0 group-hover:opacity-100 scale-x-50 group-hover:scale-x-100 origin-left transition-all absolute whitespace-nowrap top-1/2 -translate-y-1/2 left-[150%] pointer-events-none border-gray-700/50 border-[1px] bg-[#1F2428] shadow-md shadow-[#1d1d1dc7] rounded-xl px-2 py-1"> - {name} - </div> - </div> - ); + <DialogTrigger + className={`flex flex-col items-center cursor-pointer text-white`} + > + <Image + src={AddIcon} + alt="Logo" + width={24} + height={24} + className="hover:brightness-125 focus:brightness-125 duration-200 stroke-white" + /> + <p className="text-xs text-foreground-menu mt-2">Add</p> + </DialogTrigger> + {menuItems.slice(1, 2).map((item) => ( + <Link + aria-disabled={item.disabled} + href={item.disabled ? "#" : item.url} + key={item.url} + className={`flex flex-col items-center ${ + item.disabled + ? "opacity-50 cursor-not-allowed" + : "cursor-pointer" + }`} + onClick={(e) => item.disabled && e.preventDefault()} + > + <Image + src={item.icon} + alt={`${item.text} icon`} + width={24} + height={24} + /> + <p className="text-xs text-foreground-menu mt-2">{item.text}</p> + </Link> + ))} + </div> + </div> + </Dialog> + </> + ); } export default Menu; diff --git a/apps/web/app/(landing)/Hero.tsx b/apps/web/app/(landing)/Hero.tsx index 4a9a8e04..1cdfced8 100644 --- a/apps/web/app/(landing)/Hero.tsx +++ b/apps/web/app/(landing)/Hero.tsx @@ -53,7 +53,7 @@ function Hero() { </motion.p> <Link href="/signin" - className="inline-flex text-lg gap-x-2 mt-2 backdrop-blur-md text-white justify-center items-center py-3 px-5 ml-3 w-fit rounded-3xl border duration-200 group bg-page-gradient border-white/30 text-md font-geistSans hover:border-zinc-600 hover:bg-transparent/10 hover:text-zinc-100" + className="inline-flex text-lg gap-x-2 mt-2 backdrop-blur-md text-white justify-center items-center py-3 px-5 w-fit rounded-3xl border duration-200 group bg-page-gradient border-white/30 text-md font-geistSans hover:border-zinc-600 hover:bg-transparent/10 hover:text-zinc-100" > It's free. Sign up now <div className="flex overflow-hidden relative justify-center items-center ml-1 w-5 h-5"> @@ -61,22 +61,38 @@ function Hero() { <ArrowUpRight className="absolute transition-all duration-500 -translate-x-4 -translate-y-5 group-hover:translate-x-0 group-hover:translate-y-0" /> </div> </Link> + <a + href="https://www.producthunt.com/posts/supermemory?embed=true&utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-supermemory" + target="_blank" + > + <img + src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=472686&theme=dark&period=daily" + alt="Supermemory - AI second brain for all your saved stuff | Product Hunt" + style={{ width: "250px", height: "54px" }} + width="250" + height="54" + /> + </a> </section> <AnimatedLogoCloud /> <div className="relative z-50"> - <motion.img + <motion.iframe {...{ ...slap, transition: { ...slap.transition, delay: 0.35 }, }} - src="/images/landing-hero.jpeg" - alt="Landing page background" draggable="false" - className="z-40 md:mt-[-40px] hidden sm:block h-full max-w-[70vw] mx-auto md:w-full select-none px-5 !rounded-2xl" + className="z-40 relative md:mt-[-40px] hidden sm:block h-full max-w-[70vw] mx-auto md:w-full select-none px-5 !rounded-2xl" style={{ borderRadius: "20px", + width: "100%", + height: "100%", }} + src="https://customer-5xczlbkyq4f9ejha.cloudflarestream.com/111c4828c3587348bc703e67bfca9682/iframe?preload=true&poster=https%3A%2F%2Fcustomer-5xczlbkyq4f9ejha.cloudflarestream.com%2F111c4828c3587348bc703e67bfca9682%2Fthumbnails%2Fthumbnail.jpg%3Ftime%3D%26height%3D600" + loading="lazy" + allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;" + allowFullScreen={true} /> <div className="absolute -z-10 left-0 top-[10%] h-32 w-[90%] overflow-x-hidden bg-[rgb(54,157,253)] bg-opacity-100 blur-[337.4px]" diff --git a/apps/web/app/(quicklinks)/ph/route.ts b/apps/web/app/(quicklinks)/ph/route.ts new file mode 100644 index 00000000..7355c24c --- /dev/null +++ b/apps/web/app/(quicklinks)/ph/route.ts @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export async function GET() { + return redirect("https://www.producthunt.com/posts/supermemory"); +} diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index f3677f4c..da2bfb5f 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -22,6 +22,34 @@ import { ChatHistory } from "@repo/shared-types"; import { decipher } from "@/server/encrypt"; import { redirect } from "next/navigation"; import { tweetToMd } from "@repo/shared-types/utils"; +import { ensureAuth } from "../api/ensureAuth"; +import { getRandomSentences } from "@/lib/utils"; +import { getRequestContext } from "@cloudflare/next-on-pages"; + +export const completeOnboarding = async (): ServerActionReturnType<boolean> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + try { + const res = await db + .update(users) + .set({ hasOnboarded: true }) + .where(eq(users.id, data.user.id)) + .returning({ hasOnboarded: users.hasOnboarded }); + + if (res.length === 0 || !res[0]?.hasOnboarded) { + return { success: false, data: false, error: "Failed to update user" }; + } + + return { success: true, data: res[0].hasOnboarded }; + } catch (e) { + return { success: false, data: false, error: (e as Error).message }; + } +}; export const createSpace = async ( input: string | FormData, @@ -468,6 +496,7 @@ export const createChatObject = async ( answer: lastChat.answer.parts.map((part) => part.text).join(""), answerSources: JSON.stringify(lastChat.answer.sources), threadId, + createdAt: new Date(), }); if (!saved) { @@ -744,3 +773,122 @@ export async function AddCanvasInfo({ }; } } + +export async function getQuerySuggestions() { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + const { env } = getRequestContext(); + + try { + const recommendations = await env.RECOMMENDATIONS.get(data.user.id); + + if (recommendations) { + return { + success: true, + data: JSON.parse(recommendations), + }; + } + + // Randomly choose some storedContent of the user. + const content = await db + .select() + .from(storedContent) + .where(eq(storedContent.userId, data.user.id)) + .orderBy(sql`random()`) + .limit(5) + .all(); + + if (content.length === 0) { + return { + success: true, + data: [], + }; + } + + const fullQuery = content + .map((c) => `${c.title} \n\n${c.content}`) + .join(" "); + + const suggestionsCall = (await env.AI.run( + // @ts-ignore + "@cf/meta/llama-3.1-8b-instruct", + { + messages: [ + { + role: "system", + content: `You are a model that suggests questions based on the user's content. you MUST suggest atleast 1 question to ask. AT MAX, create 3 suggestions. not more than that.`, + }, + { + role: "user", + content: `Run the function based on this input: ${fullQuery.slice(0, 2000)}`, + }, + ], + tools: [ + { + type: "function", + function: { + name: "querySuggestions", + description: + "Take the user's content to suggest some good questions that they could ask.", + parameters: { + type: "object", + properties: { + querySuggestions: { + type: "array", + description: + "Short questions that the user can ask. Give atleast 3 suggestions. No more than 5.", + items: { + type: "string", + }, + }, + }, + required: ["querySuggestions"], + }, + }, + }, + ], + }, + )) as { + response: string; + tool_calls: { name: string; arguments: { querySuggestions: string[] } }[]; + }; + + console.log( + "I RAN AN AI CALLS OWOWOWOWOW", + JSON.stringify(suggestionsCall, null, 2), + ); + + const suggestions = + suggestionsCall.tool_calls?.[0]?.arguments?.querySuggestions; + + if (!suggestions || suggestions.length === 0) { + return { + success: false, + error: "Failed to get query suggestions", + }; + } + + if (suggestions.length > 0) { + await env.RECOMMENDATIONS.put(data.user.id, JSON.stringify(suggestions), { + expirationTtl: 60 * 2, + }); + } + + return { + success: true, + data: suggestions, + }; + } catch (exception) { + const error = exception as Error; + return { + success: false, + error: error.message, + data: [], + }; + } +} diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts index 7071ecb3..8d6802a7 100644 --- a/apps/web/app/actions/fetchers.ts +++ b/apps/web/app/actions/fetchers.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, eq, exists, inArray, not, or, sql } from "drizzle-orm"; +import { and, asc, eq, exists, not, or } from "drizzle-orm"; import { db } from "../../server/db"; import { canvas, @@ -13,15 +13,32 @@ import { spacesAccess, storedContent, StoredSpace, + User, users, } from "../../server/db/schema"; -import { ServerActionReturnType, Space } from "./types"; +import { ServerActionReturnType } from "./types"; import { auth } from "../../server/auth"; import { ChatHistory, SourceZod } from "@repo/shared-types"; import { z } from "zod"; import { redirect } from "next/navigation"; import { cookies, headers } from "next/headers"; +export const getUser = async (): ServerActionReturnType<User> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + redirect("/signin"); + return { error: "Not authenticated", success: false }; + } + + console.log("data.user.id", data.user.id); + const user = await db.query.users.findFirst({ + where: eq(users.id, data.user.id), + }); + + return { success: true, data: user }; +}; + export const getSpaces = async (): ServerActionReturnType<StoredSpace[]> => { const data = await auth(); @@ -218,6 +235,9 @@ export const getFullChatThread = async ( ], sources: sources ?? [], }, + proModeProcessing: { + queries: [], + }, }; }, ); diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 004bfd3b..a14c96df 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -7,6 +7,10 @@ import { } from "@repo/shared-types"; import { ensureAuth } from "../ensureAuth"; import { z } from "zod"; +import { db } from "@/server/db"; +import { chatHistory as chatHistoryDb, chatThreads } from "@/server/db/schema"; +import { and, eq, gt, sql } from "drizzle-orm"; +import { join } from "path"; export const runtime = "edge"; @@ -21,12 +25,56 @@ export async function POST(req: NextRequest) { return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 }); } + const ip = req.headers.get("cf-connecting-ip"); + + if (ip) { + if (process.env.RATELIMITER) { + const { success } = await process.env.RATELIMITER.limit({ + key: `chat-${ip}`, + }); + + if (!success) { + console.error("rate limit exceeded"); + return new Response("Rate limit exceeded", { status: 429 }); + } + } else { + console.info("RATELIMITER not found in env"); + } + } else { + console.info("cf-connecting-ip not found in headers"); + } + + const lastHour = new Date(new Date().getTime() - 3600000); + + // Only allow 5 requests per hour for each user, something lke this but this one is bad because chathistory.userid doesnt exist, we have to do a join and get it from the threads table + const result = await db + .select({ + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(chatHistoryDb) + .innerJoin(chatThreads, eq(chatHistoryDb.threadId, chatThreads.id)) + .where( + and( + eq(chatThreads.userId, session.user.id), + gt(chatHistoryDb.createdAt, lastHour), + ), + ) + .execute(); + + if (result[0]?.count && result[0]?.count >= 5) { + // return new Response(`Too many requests ${result[0]?.count}`, { status: 429 }); + console.log(result[0]?.count); + } else { + console.log("count", result); + } + const url = new URL(req.url); const query = url.searchParams.get("q"); const spaces = url.searchParams.get("spaces"); const sourcesOnly = url.searchParams.get("sourcesOnly") ?? "false"; + const proMode = url.searchParams.get("proMode") === "true"; const jsonRequest = (await req.json()) as { chatHistory: ChatHistory[]; @@ -55,7 +103,7 @@ export async function POST(req: NextRequest) { const modelCompatible = await convertChatHistoryList(validated.data); const resp = await fetch( - `${process.env.BACKEND_BASE_URL}/api/chat?query=${query}&user=${session.user.id}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, + `${process.env.BACKEND_BASE_URL}/api/chat?query=${query}&user=${session.user.id}&sourcesOnly=${sourcesOnly}&spaces=${spaces}&proMode=${proMode}`, { headers: { Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`, diff --git a/apps/web/app/api/store/route.ts b/apps/web/app/api/store/route.ts index f9ab7c01..992c2a0e 100644 --- a/apps/web/app/api/store/route.ts +++ b/apps/web/app/api/store/route.ts @@ -4,7 +4,7 @@ import { ensureAuth } from "../ensureAuth"; import { z } from "zod"; import { db } from "@/server/db"; import { contentToSpace, space, storedContent } from "@/server/db/schema"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, gt, inArray, sql } from "drizzle-orm"; import { LIMITS } from "@/lib/constants"; import { limit } from "@/app/actions/doers"; @@ -22,6 +22,29 @@ const createMemoryFromAPI = async (input: { }; } + // Get number of items saved in the last 2 hours + const last2Hours = new Date(Date.now() - 2 * 60 * 60 * 1000); + + const numberOfItemsSavedInLast2Hours = await db + .select({ + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(storedContent) + .where( + and( + gt(storedContent.savedAt, last2Hours), + eq(storedContent.userId, input.userId), + ), + ); + + if (numberOfItemsSavedInLast2Hours[0]!.count >= 20) { + return { + success: false, + data: 0, + error: `You have exceeded the limit`, + }; + } + const vectorSaveResponse = await fetch( `${process.env.BACKEND_BASE_URL}/api/add`, { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index cf6e9b0f..94a538ed 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -66,9 +66,14 @@ export default function RootLayout({ }): JSX.Element { return ( <html lang="en" className="overflow-x-hidden" suppressHydrationWarning> - {/* <head> - <ThemeScript /> - </head> */} + <head> + {/* Cloudflare web analytics */} + <script + defer + src="https://static.cloudflareinsights.com/beacon.min.js" + data-cf-beacon='{"token": "16d76ebb82c74d9983b71d09ab6617bc"}' + ></script> + </head> {/* TODO: when lightmode support is added, remove the 'dark' class from the body tag */} <body className={cn( diff --git a/apps/web/cf-env.d.ts b/apps/web/cf-env.d.ts index 2c77d4fb..09b0690b 100644 --- a/apps/web/cf-env.d.ts +++ b/apps/web/cf-env.d.ts @@ -22,6 +22,10 @@ declare global { THREAD_CF_AUTH: string; MOBILE_TRUST_TOKEN: string; + + RATELIMITER: { + limit: ({ key: string }) => { success: boolean }; + }; } } } diff --git a/apps/web/env.d.ts b/apps/web/env.d.ts index b6a410f9..c80ac0a4 100644 --- a/apps/web/env.d.ts +++ b/apps/web/env.d.ts @@ -6,4 +6,6 @@ interface CloudflareEnv { DATABASE: D1Database; DEV_IMAGES: R2Bucket; CANVAS_SNAPS: KVNamespace; + AI: Ai; + RECOMMENDATIONS: KVNamespace; } diff --git a/apps/web/lib/searchParams.ts b/apps/web/lib/searchParams.ts index 2e8b1633..b90b560c 100644 --- a/apps/web/lib/searchParams.ts +++ b/apps/web/lib/searchParams.ts @@ -32,4 +32,5 @@ export const chatSearchParamsCache = createSearchParamsCache({ return valid.data; }), + proMode: parseAsBoolean.withDefault(false), }); diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts new file mode 100644 index 00000000..98ec6e98 --- /dev/null +++ b/apps/web/lib/utils.ts @@ -0,0 +1,23 @@ +export function getRandomSentences(fullQuery: string): string { + // Split the fullQuery into sentences + const sentences = fullQuery.match(/[^.!?]+[.!?]+/g) || []; + + // Function to get a random integer between min and max + function getRandomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min)) + min; + } + + let selectedSentences = ""; + let totalCharacters = 0; + + // Select random sentences until totalCharacters is at least 1000 + while (totalCharacters < 1000 && sentences.length > 0) { + const randomIndex = getRandomInt(0, sentences.length); + const sentence = sentences[randomIndex]; + selectedSentences += sentence; + totalCharacters += sentence?.length || 0; + sentences.splice(randomIndex, 1); // Remove the selected sentence from the array + } + + return selectedSentences; +} diff --git a/apps/web/migrations/0000_exotic_sway.sql b/apps/web/migrations/0000_steep_moira_mactaggert.sql index 65a41795..5813639d 100644 --- a/apps/web/migrations/0000_exotic_sway.sql +++ b/apps/web/migrations/0000_steep_moira_mactaggert.sql @@ -43,6 +43,7 @@ CREATE TABLE `chatHistory` ( `answerParts` text, `answerSources` text, `answerJustification` text, + `createdAt` integer DEFAULT '"2024-07-25T22:31:50.848Z"' NOT NULL, FOREIGN KEY (`threadId`) REFERENCES `chatThread`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint @@ -106,7 +107,8 @@ CREATE TABLE `user` ( `email` text NOT NULL, `emailVerified` integer, `image` text, - `telegramId` text + `telegramId` text, + `hasOnboarded` integer DEFAULT false ); --> statement-breakpoint CREATE TABLE `verificationToken` ( diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index 0639eb77..a7689010 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "ab91d972-05ff-4916-84b7-1cfaab4c3879", + "id": "8705302a-eae7-4fbf-9ce8-8ae23df228a2", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -298,6 +298,14 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'\"2024-07-25T22:31:50.848Z\"'" } }, "indexes": { @@ -738,6 +746,14 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "hasOnboarded": { + "name": "hasOnboarded", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false } }, "indexes": { diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index c7ab51e1..d79e2607 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1720360287793, - "tag": "0000_exotic_sway", + "when": 1721946710900, + "tag": "0000_steep_moira_mactaggert", "breakpoints": true } ] diff --git a/apps/web/server/db/index.ts b/apps/web/server/db/index.ts index 4d671bea..a9ec9106 100644 --- a/apps/web/server/db/index.ts +++ b/apps/web/server/db/index.ts @@ -2,4 +2,7 @@ import { drizzle } from "drizzle-orm/d1"; import * as schema from "./schema"; -export const db = drizzle(process.env.DATABASE, { schema, logger: true }); +export const db = drizzle(process.env.DATABASE, { + schema, + logger: process.env.NODE_ENV === "development", +}); diff --git a/apps/web/server/db/schema.ts b/apps/web/server/db/schema.ts index ae293a91..32b80719 100644 --- a/apps/web/server/db/schema.ts +++ b/apps/web/server/db/schema.ts @@ -1,3 +1,4 @@ +import { create } from "domain"; import { relations, sql } from "drizzle-orm"; import { index, @@ -22,6 +23,7 @@ export const users = createTable( emailVerified: integer("emailVerified", { mode: "timestamp_ms" }), image: text("image"), telegramId: text("telegramId"), + hasOnboarded: integer("hasOnboarded", { mode: "boolean" }).default(false), }, (user) => ({ emailIdx: index("users_email_idx").on(user.email), @@ -210,6 +212,9 @@ export const chatHistory = createTable( answer: text("answerParts"), // Single answer part as string answerSources: text("answerSources"), // JSON stringified array of objects answerJustification: text("answerJustification"), + createdAt: int("createdAt", { mode: "timestamp" }) + .notNull() + .default(new Date()), }, (history) => ({ threadIdx: index("chatHistory_thread_idx").on(history.threadId), diff --git a/apps/web/wrangler.toml b/apps/web/wrangler.toml index ce38285b..7f3fa047 100644 --- a/apps/web/wrangler.toml +++ b/apps/web/wrangler.toml @@ -3,6 +3,20 @@ compatibility_date = "2024-03-29" compatibility_flags = [ "nodejs_compat" ] pages_build_output_dir = ".vercel/output/static" + +kv_namespaces = [ + { binding = "CANVAS_SNAPS", id = "6df98c892b3744ccb0c631d9f60d6697" }, + { binding = "RECOMMENDATIONS", id = "83bc7055226c4657948141c2ff9a5425" } +] + +env.production.kv_namespaces = [ + { binding = "CANVAS_SNAPS", id = "6df98c892b3744ccb0c631d9f60d6697" }, + { binding = "RECOMMENDATIONS", id = "83bc7055226c4657948141c2ff9a5425" } +] + +[ai] +binding = "AI" + [placement] mode = "smart" @@ -15,16 +29,19 @@ binding = "DATABASE" database_name = "dev-d1-anycontext" database_id = "fc562605-157a-4f60-b439-2a24ffed5b4c" -[[kv_namespaces]] -binding = "CANVAS_SNAPS" -id = "c6446f7190dd4afebe1c318df3400518" [[env.production.d1_databases]] binding = "DATABASE" database_name = "prod-d1-supermemory" database_id = "f527a727-c472-41d4-8eaf-3d7ba0f2f395" +[env.preview.ai] +binding = "AI" + [[env.preview.d1_databases]] binding = "DATABASE" database_name = "dev-d1-anycontext" -database_id = "fc562605-157a-4f60-b439-2a24ffed5b4c"
\ No newline at end of file +database_id = "fc562605-157a-4f60-b439-2a24ffed5b4c" + +[env.production.ai] +binding = "AI"
\ No newline at end of file |