diff options
45 files changed, 1168 insertions, 396 deletions
diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cc672fed --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb
\ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4bc75484..15394cb2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "css.validate": false, "editor.quickSuggestions": { "strings": true - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/SETUP-GUIDE.md b/SETUP-GUIDE.md index 1ebff77e..72052931 100644 --- a/SETUP-GUIDE.md +++ b/SETUP-GUIDE.md @@ -1,81 +1,134 @@ -# Setup guide +# Self Hosting Guide + +This guide will help you set up your own instance of Supermemory. This is neccessary if you want to contribute to the project or if you want to self host the project. You can read more about the stack [here](https://github.com/supermemoryai/supermemory/?tab=readme-ov-file#-the-stack). ## Prerequisites - [bun](https://bun.sh/) - [turbo](https://turbo.build/repo/docs/installing) - [wrangler](https://developers.cloudflare.com/workers/cli-wrangler/install-update) +- [yarn](https://yarnpkg.com/getting-started/install): yarn is required to run scripts using turborepo. bun is not supported by turborepo yet vercel/turbo#4762 +- [Cloudflare Workers](https://developers.cloudflare.com/workers/platform/pricing/): You also need to have a paid Workers plan to use the vectorize feature which is needed run the AI backend. It is currently $5/mo + usage costs. +- [Cloudflare R2](https://developers.cloudflare.com/r2/): You need to enable R2 in the Cloudflare Dashboard for use in the web app. ## Steps 1. Clone the repo 2. Run `bun install` in the root directory -3. Create a `.dev.vars` file in `apps/web` with the following content: + +### web + +1. You need to create OAuth credentials for Google which is need for auth.js (nextauth). Visit https://developers.google.com/identity/protocols/oauth2 to learn more and https://console.cloud.google.com/apis/dashboard to create a new project and OAuth credentials. You need to set the redirect URL to `http://localhost:3000/api/auth/callback/google` for development. You can also set the redirect URL to your own domain if you are deploying the app. +2. Create a `.dev.vars` file in `apps/web` with the following content: ```bash -GOOGLE_CLIENT_ID="-" // required, visit https://developers.google.com/identity/protocols/oauth2 -GOOGLE_CLIENT_SECRET="-" // required -NEXTAUTH_SECRET='nextauthsecret' +GOOGLE_CLIENT_ID="" // required +GOOGLE_CLIENT_SECRET="" // required +NEXTAUTH_SECRET="" // generate by running `openssl rand -base64 32` DATABASE_URL='database.sqlite' NEXTAUTH_URL='http://localhost:3000' -BACKEND_SECURITY_KEY='veryrandomsecuritykey' -BACKEND_BASE_URL="where your backend is hosted" +BACKEND_SECURITY_KEY="" // used to authenticate with the backend. generate a random string using `openssl rand -base64 32` +BACKEND_BASE_URL="http://localhost:8686" +``` + +> [!NOTE] +> The `BACKEND_SECURITY_KEY` should be the same as the `SECURITY_KEY` in the `.dev.vars` file in `apps/cf-ai-backend`. + +3. KV Namespaces + +```bash +bunx wrangler kv namespace create canvas-snaps ``` -4. Setup the database: +```bash +bunx wrangler kv namespace create recommendations +``` -First, edit the `wrangler.toml` file in `apps/web` to point the d1 database to your account. +Do not change the binding value in the `wrangler.toml` but update the id for the namespaces with the values you get from the above commands. -You can create a d1 database by running this command +4. R2 Storage +```bash +bunx wrangler r2 bucket create supermemory-r2 ``` -bunx wrangler d1 create <YOUR_DATABASE_NAME> + +Update bucket_name in the `wrangler.toml` file in `apps/web` to `supermemory-r2` + +5. D1 Database + +```bash +bunx wrangler d1 create supermemory-db-prod +``` + +Update the database_name and database_id in `[[env.production.d1_databases]]` with the values you get from the above command. + +```bash +bunx wrangler d1 create supermemory-db-preview ``` -And then replace database_name and database_id with the values +Update the database_name and database_id in `[[d1_databases]]` and `[[env.preview.d1_databases]]` with the values you get from the above command. > [!NOTE] > please don't change the binding value even if wrangler cli suggests you to do so. -``` +```bash [[d1_databases]] binding = "DATABASE" -database_name = "YOUR_DATABASE_NAME" +database_name = "supermemory-db-preview" database_id = "YOUR_DB_ID" ``` Simply run this command in `apps/web` -``` -bunx wrangler d1 migrations apply <YOUR_DATABASE_NAME> +```bash +bunx wrangler d1 migrations apply supermemory-db-preview ``` -If it runs, you can set up the cloud database as well by removing the `--local` flag, +If it runs, you can set up the cloud database as well by add the `--remote` flag, if you just want to contribute to frontend then just run `bun run dev` in the root of the project and done! (you won't be able to try ai stuff), otherwise continue... -5. You need to host your own worker for the `apps/cf-ai-backend` module. +### cf-ai-backend + +1. You need to host your own worker for the `apps/cf-ai-backend` module. To do this, first edit the `.dev.vars` file in `apps/cf-ai-backend` with the following content: ```bash -SECURITY_KEY="veryrandomsecuritykey" +SECURITY_KEY="veryrandomsecuritykey" // same as BACKEND_SECURITY_KEY in web // Why? to generate embeddings with 4000+ tokens OPENAI_API_KEY="sk-" ``` -6. Run this command to initialise vector database +2. Run this command to initialise vector database > Note: You need to use the workers paid plan to use vectorize for now. +```bash +bunx wrangler vectorize create --dimensions=1536 supermemory --metric=cosine +``` + +Update the index_name for `[[vectorize]]` in `wrangler.toml` file in `apps/cf-ai-backend` with the `supermemory` or the name you used in the above command. + +3. Create KV namespaces for the `cf-ai-backend` module + +```bash +bunx wrangler kv namespace create prod ``` -wrangler vectorize create --dimensions=1536 supermem-vector-1 --metric=cosine + +Update the id in `[[kv_namespaces]]` in the `wrangler.toml` file in `apps/cf-ai-backend` with the value you get from the above command. + +```bash +bunx wrangler kv namespace create preview ``` -7. Change the `wrangler.toml` file in `apps/cf-ai-backend` to point to your KV namespace +Update the preview_id in `[[kv_namespaces]]` in the `wrangler.toml` file in `apps/cf-ai-backend` with the value you get from the above command. -8. Run `bun dev` in the root directory and Voila! You have your own supermemory instance running! +## Local Development -> Note: You need to replace the url `https://cf-ai-backend.dhr.wtf` everywhere with your own url for the cf-ai-backend module. +- Run `bun dev` in the root directory and Voila! You have your own supermemory instance running! + +> [!NOTE] +> It sometimes takes multiple tries to successfully run the `bun dev` command. If you encounter any issues, try running the command again. ## Deploying diff --git a/apps/cf-ai-backend/src/index.ts b/apps/cf-ai-backend/src/index.ts index cf6507c2..a3ac1380 100644 --- a/apps/cf-ai-backend/src/index.ts +++ b/apps/cf-ai-backend/src/index.ts @@ -69,43 +69,42 @@ app.get("/api/health", (c) => { }); app.post("/api/add", zValidator("json", vectorObj), async (c) => { - try{ - const body = c.req.valid("json"); - - const { store } = await initQuery(c); + try { + const body = c.req.valid("json"); - console.log(body.spaces); - let chunks: TweetChunks | PageOrNoteChunks; - // remove everything in <raw> tags - const newPageContent = body.pageContent?.replace(/<raw>.*?<\/raw>/g, ""); + const { store } = await initQuery(c); - switch (body.type) { - case "tweet": - chunks = chunkThread(newPageContent); - break; + console.log(body.spaces); + let chunks: TweetChunks | PageOrNoteChunks; + // remove everything in <raw> tags + const newPageContent = body.pageContent?.replace(/<raw>.*?<\/raw>/g, ""); - case "page": - chunks = chunkPage(newPageContent); - break; + switch (body.type) { + case "tweet": + chunks = chunkThread(newPageContent); + break; - case "note": - chunks = chunkNote(newPageContent); - break; - } + case "page": + chunks = chunkPage(newPageContent); + break; + case "note": + chunks = chunkNote(newPageContent); + break; + } - await batchCreateChunksAndEmbeddings({ - store, - body, - chunks: chunks, - context: c, - }); + await batchCreateChunksAndEmbeddings({ + store, + body, + chunks: chunks, + context: c, + }); - return c.json({ status: "ok" }); -}catch(error){ - console.error("Error processing request:", error); - return c.json({ status: "error", message: error.message }, 500); -} + return c.json({ status: "ok" }); + } catch (error) { + console.error("Error processing request:", error); + return c.json({ status: "error", message: error.message }, 500); + } }); app.post( @@ -425,6 +424,7 @@ app.post( spaces: z.string().optional(), sourcesOnly: z.string().optional().default("false"), model: z.string().optional().default("gpt-4o"), + proMode: z.string().optional().default("false"), }), ), zValidator("json", chatObj), @@ -433,6 +433,7 @@ app.post( const body = c.req.valid("json"); const sourcesOnly = query.sourcesOnly === "true"; + const proMode = query.proMode === "true"; // Return early for dumb requests if (sourcesOnly && body.sources) { @@ -440,7 +441,6 @@ app.post( } const spaces = query.spaces?.split(",") ?? [undefined]; - console.log(spaces); // Get the AI model maker and vector store const { model, store } = await initQuery(c, query.model); @@ -449,14 +449,67 @@ app.post( const filter: VectorizeVectorMetadataFilter = { [`user-${query.user}`]: 1, }; - console.log("Spaces", spaces); + + let proModeListedQueries: string[] = []; + + if (proMode) { + const addedToQuery = (await c.env.AI.run( + // @ts-ignore + "@hf/nousresearch/hermes-2-pro-mistral-7b", + { + messages: [ + { + role: "system", + content: + "You are a query enhancer. You must enhance a user's query to make it more relevant to what the user might be looking for. If there's any mention of dates like 'last summer' or 'this year', you should return 'DAY: X, MONTH: Y, YEAR: Z'. If there's any mention of locations, add that to the query too. Try to keep your responses as short as possible. Add to the user's query, don't replace it. Make sure to keep your answers short.", + }, + { role: "user", content: query.query }, + ], + tools: [ + { + type: "function", + function: { + name: "Enhance query get list", + description: + "Enhance the user's query to make it more relevant", + parameters: { + type: "object", + properties: { + listedQueries: { + type: "array", + description: "List of queries that the user has asked", + items: { + type: "string", + }, + }, + }, + required: ["Enhance query get list"], + }, + }, + }, + ], + max_tokens: 200, + }, + )) as { + response?: string; + tool_calls?: { + name: string; + arguments: { + listedQueries: string[]; + }; + }[]; + }; + + proModeListedQueries = + addedToQuery.tool_calls?.[0]?.arguments?.listedQueries ?? []; + } // Converting the query to a vector so that we can search for similar vectors - const queryAsVector = await store.embeddings.embedQuery(query.query); + const queryAsVector = await store.embeddings.embedQuery( + query.query + " " + proModeListedQueries.join(" "), + ); const responses: VectorizeMatches = { matches: [], count: 0 }; - console.log("hello world", spaces); - // SLICED to 5 to avoid too many queries for (const space of spaces.slice(0, 5)) { if (space && space.length >= 1) { @@ -523,7 +576,13 @@ app.post( ); const metadata = normalizedData.map((datapoint) => datapoint.metadata); - return c.json({ ids: storedContent, metadata, normalizedData }); + + return c.json({ + ids: storedContent, + metadata, + normalizedData, + proModeListedQueries, + }); } } diff --git a/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts b/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts index 4a6ff593..a151afc0 100644 --- a/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts +++ b/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts @@ -50,6 +50,7 @@ export class OpenAIEmbeddings { const json = zodTypeExpected.safeParse(data); if (!json.success) { + console.log(JSON.stringify(data)); throw new Error("Invalid response from OpenAI: " + json.error.message); } diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index 0edcd320..7f998412 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "$schema": "https://json.schemastore.org/chrome-manifest", - "version": "2.63", + "version": "2.63.1", "name": "supermemory", "description": "An extension for https://supermemory.ai - an AI hub for all your bookmarks.", "background": { 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 diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index b3e84897..051e24a4 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -17,6 +17,9 @@ export const ChatHistoryZod = z.object({ sources: z.array(SourceZod), justification: z.string().optional(), }), + proModeProcessing: z.object({ + queries: z.array(z.string()), + }), }); export type ChatHistory = z.infer<typeof ChatHistoryZod>; @@ -77,6 +80,7 @@ export const sourcesZod = z.object({ ids: z.array(z.string()), metadata: z.array(z.any()), normalizedData: z.array(z.any()).optional(), + proModeListedQueries: z.array(z.string()).optional(), }); export type SourcesFromApi = z.infer<typeof sourcesZod>; diff --git a/packages/ui/icons/home.svg b/packages/ui/icons/home.svg new file mode 100644 index 00000000..16ddf88d --- /dev/null +++ b/packages/ui/icons/home.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="24" height="24" className="size-5"> + <path fill="#fff" fillRule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z" clipRule="evenodd" /> +</svg> diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts index 516e3c81..cdbc0024 100644 --- a/packages/ui/icons/index.ts +++ b/packages/ui/icons/index.ts @@ -9,12 +9,12 @@ import SearchIcon from "./search.svg"; import NextIcon from "./nextarrow.svg"; import UrlIcon from "./url.svg"; import CanvasIcon from "./canvas.svg"; -import blockIcon from "./block.svg"; import LinkIcon from "./link.svg"; import AutocompleteIcon from "./autocomplete.svg"; import BlockIcon from "./block.svg"; import DragIcon from "./drag.svg"; import SettingsIcon from "./settings.svg"; +import HomeIcon from "./home.svg"; export { AddIcon, @@ -28,10 +28,10 @@ export { NextIcon, UrlIcon, CanvasIcon, - blockIcon, LinkIcon, AutocompleteIcon, BlockIcon, DragIcon, SettingsIcon, + HomeIcon, }; diff --git a/packages/ui/shadcn/switch.tsx b/packages/ui/shadcn/switch.tsx index bf02d835..c5961182 100644 --- a/packages/ui/shadcn/switch.tsx +++ b/packages/ui/shadcn/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( <SwitchPrimitives.Root className={cn( - "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-white data-[state=unchecked]:bg-[#409EFD1A]", className, )} {...props} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2e64c120..55fbf930 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist" }, "exclude": ["node_modules", "dist"], - "include": ["shadcn", "icons", "hooks", "components"] + "include": ["shadcn", "icons", "hooks", "components", "types.d.ts"] } diff --git a/packages/ui/tsconfig.lint.json b/packages/ui/tsconfig.lint.json index 2e64c120..55fbf930 100644 --- a/packages/ui/tsconfig.lint.json +++ b/packages/ui/tsconfig.lint.json @@ -4,5 +4,5 @@ "outDir": "dist" }, "exclude": ["node_modules", "dist"], - "include": ["shadcn", "icons", "hooks", "components"] + "include": ["shadcn", "icons", "hooks", "components", "types.d.ts"] } diff --git a/packages/ui/types.d.ts b/packages/ui/types.d.ts new file mode 100644 index 00000000..51b04577 --- /dev/null +++ b/packages/ui/types.d.ts @@ -0,0 +1,4 @@ +declare module "*.svg" { + const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>; + export default content; +} |