diff options
| author | Kartik <[email protected]> | 2024-06-15 20:36:41 +0530 |
|---|---|---|
| committer | Kartik <[email protected]> | 2024-06-15 20:36:41 +0530 |
| commit | 8ecfc654c77262a40f8458b2b4e44c163ad094bd (patch) | |
| tree | 1c2b3ad23bb55808acf98f0229b13d2f7e8649ef | |
| parent | chore: Remove unused variables and dependencies (diff) | |
| parent | getspaces and other features integrated with the backend (diff) | |
| download | supermemory-8ecfc654c77262a40f8458b2b4e44c163ad094bd.tar.xz supermemory-8ecfc654c77262a40f8458b2b4e44c163ad094bd.zip | |
Merge branch 'codetorso' of https://github.com/Dhravya/supermemory into codetorso
36 files changed, 1259 insertions, 310 deletions
diff --git a/apps/cf-ai-backend/src/index.ts b/apps/cf-ai-backend/src/index.ts index 1e128bbd..2dbb2d0c 100644 --- a/apps/cf-ai-backend/src/index.ts +++ b/apps/cf-ai-backend/src/index.ts @@ -88,8 +88,8 @@ app.post( "query", z.object({ query: z.string(), - topK: z.number().optional().default(10), user: z.string(), + topK: z.number().optional().default(10), spaces: z.string().optional(), sourcesOnly: z.string().optional().default("false"), model: z.string().optional().default("gpt-4o"), @@ -100,28 +100,25 @@ app.post( const query = c.req.valid("query"); const body = c.req.valid("json"); - if (body.chatHistory) { - body.chatHistory = body.chatHistory.map((i) => ({ - ...i, - content: i.parts.length > 0 ? i.parts.join(" ") : i.content, - })); - } - const sourcesOnly = query.sourcesOnly === "true"; - const spaces = query.spaces?.split(",") || [undefined]; + const spaces = query.spaces?.split(",") ?? [""]; // Get the AI model maker and vector store const { model, store } = await initQuery(c, query.model); const filter: VectorizeVectorMetadataFilter = { user: query.user }; + console.log("Spaces", spaces); // Converting the query to a vector so that we can search for similar vectors const queryAsVector = await store.embeddings.embedQuery(query.query); 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 !== undefined) { + console.log("space", space); + if (space !== "") { // it's possible for space list to be [undefined] so we only add space filter conditionally filter.space = space; } diff --git a/apps/cf-ai-backend/src/prompts/prompt1.ts b/apps/cf-ai-backend/src/prompts/prompt1.ts index aa7694d3..d2ee988c 100644 --- a/apps/cf-ai-backend/src/prompts/prompt1.ts +++ b/apps/cf-ai-backend/src/prompts/prompt1.ts @@ -6,15 +6,12 @@ To generate your answer: - Carefully analyze the question and identify the key information needed to address it - Locate the specific parts of each context that contain this key information - Compare the relevance scores of the provided contexts -- In the <justification> tags, provide a brief justification for which context(s) are more relevant to answering the question based on the scores - Concisely summarize the relevant information from the higher-scoring context(s) in your own words - Provide a direct answer to the question - Use markdown formatting in your answer, including bold, italics, and bullet points as appropriate to improve readability and highlight key points - Give detailed and accurate responses for things like 'write a blog' or long-form questions. - The normalisedScore is a value in which the scores are 'balanced' to give a better representation of the relevance of the context, between 1 and 100, out of the top 10 results - -Provide your justification between <justification> tags and your final answer between <answer> tags, formatting both in markdown. - +- provide your justification in the end, in a <justification> </justification> tag If no context is provided, introduce yourself and explain that the user can save content which will allow you to answer questions about that content in the future. Do not provide an answer if no context is provided.`; export const template = ({ contexts, question }) => { diff --git a/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts b/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts index 3514f579..be5839b1 100644 --- a/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts +++ b/apps/cf-ai-backend/src/utils/OpenAIEmbedder.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + interface OpenAIEmbeddingsParams { apiKey: string; modelName: string; @@ -32,12 +34,22 @@ export class OpenAIEmbeddings { }), }); - const data = (await response.json()) as { - data: { - embedding: number[]; - }[]; - }; + const data = await response.json(); + + const zodTypeExpected = z.object({ + data: z.array( + z.object({ + embedding: z.array(z.number()), + }), + ), + }); + + const json = zodTypeExpected.safeParse(data); + + if (!json.success) { + throw new Error("Invalid response from OpenAI: " + json.error.message); + } - return data.data[0].embedding; + return json.data.data[0].embedding; } } diff --git a/apps/web/app/(dash)/actions.ts b/apps/web/app/(dash)/actions.ts deleted file mode 100644 index 70c2a567..00000000 --- a/apps/web/app/(dash)/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use server"; - -import { cookies, headers } from "next/headers"; -import { db } from "../helpers/server/db"; -import { sessions, users, space } from "../helpers/server/db/schema"; -import { eq } from "drizzle-orm"; -import { redirect } from "next/navigation"; - -export async function ensureAuth() { - const token = - cookies().get("next-auth.session-token")?.value ?? - cookies().get("__Secure-authjs.session-token")?.value ?? - cookies().get("authjs.session-token")?.value ?? - headers().get("Authorization")?.replace("Bearer ", ""); - - if (!token) { - return undefined; - } - - const sessionData = await db - .select() - .from(sessions) - .innerJoin(users, eq(users.id, sessions.userId)) - .where(eq(sessions.sessionToken, token)); - - if (!sessionData || sessionData.length < 0) { - return undefined; - } - - return { - user: sessionData[0]!.user, - session: sessionData[0]!, - }; -} - -export async function getSpaces() { - const data = await ensureAuth(); - if (!data) { - redirect("/signin"); - } - - const sp = await db - .select() - .from(space) - .where(eq(space.user, data.user.email)); - - return sp; -} diff --git a/apps/web/app/(dash)/chat/CodeBlock.tsx b/apps/web/app/(dash)/chat/CodeBlock.tsx new file mode 100644 index 00000000..0bb6a19d --- /dev/null +++ b/apps/web/app/(dash)/chat/CodeBlock.tsx @@ -0,0 +1,90 @@ +import React, { useRef, useState } from "react"; + +const CodeBlock = ({ + lang, + codeChildren, +}: { + lang: string; + codeChildren: React.ReactNode & React.ReactNode[]; +}) => { + const codeRef = useRef<HTMLElement>(null); + + return ( + <div className="bg-black rounded-md"> + <CodeBar lang={lang} codeRef={codeRef} /> + <div className="p-4 overflow-y-auto"> + <code ref={codeRef} className={`!whitespace-pre hljs language-${lang}`}> + {codeChildren} + </code> + </div> + </div> + ); +}; + +const CodeBar = React.memo( + ({ + lang, + codeRef, + }: { + lang: string; + codeRef: React.RefObject<HTMLElement>; + }) => { + const [isCopied, setIsCopied] = useState<boolean>(false); + return ( + <div className="flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans"> + <span className="">{lang}</span> + <button + className="flex ml-auto gap-2" + aria-label="copy codeblock" + onClick={async () => { + const codeString = codeRef.current?.textContent; + if (codeString) + navigator.clipboard.writeText(codeString).then(() => { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 3000); + }); + }} + > + {isCopied ? ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75" + /> + </svg> + Copied! + </> + ) : ( + <> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="size-4" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" + /> + </svg> + Copy code + </> + )} + </button> + </div> + ); + }, +); +export default CodeBlock; diff --git a/apps/web/app/(dash)/chat/actions.ts b/apps/web/app/(dash)/chat/actions.ts index 908fe79e..e69de29b 100644 --- a/apps/web/app/(dash)/chat/actions.ts +++ b/apps/web/app/(dash)/chat/actions.ts @@ -1 +0,0 @@ -"use server"; diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index 43c337ee..b631c835 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -6,29 +6,92 @@ import QueryInput from "../home/queryinput"; import { cn } from "@repo/ui/lib/utils"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; +import { ChatHistory } from "@repo/shared-types"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@repo/ui/shadcn/accordion"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import rehypeHighlight from "rehype-highlight"; +import { code, p } from "./markdownRenderHelpers"; +import { codeLanguageSubset } from "@/app/helpers/constants"; -function ChatWindow({ q }: { q: string }) { +function ChatWindow({ + q, + spaces, +}: { + q: string; + spaces: { id: string; name: string }[]; +}) { const [layout, setLayout] = useState<"chat" | "initial">("initial"); + const [chatHistory, setChatHistory] = useState<ChatHistory[]>([ + { + question: q, + answer: { + parts: [ + // { + // text: `It seems like there might be a typo in your question. Could you please clarify or provide more context? If you meant "interesting," please let me know what specific information or topic you find interesting, and I can help you with that.`, + // }, + ], + sources: [], + }, + }, + ]); const router = useRouter(); + const getAnswer = async (query: string, spaces: string[]) => { + const resp = await fetch(`/api/chat?q=${query}&spaces=${spaces}`, { + method: "POST", + body: JSON.stringify({ chatHistory }), + }); + + const reader = resp.body?.getReader(); + let done = false; + let result = ""; + while (!done && reader) { + const { value, done: d } = await reader.read(); + done = d; + + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) return prevChatHistory; + lastAnswer.answer.parts.push({ text: new TextDecoder().decode(value) }); + return newChatHistory; + }); + } + + console.log(result); + }; + useEffect(() => { - if (q !== "") { + if (q.trim().length > 0) { + getAnswer( + q, + spaces.map((s) => s.id), + ); setTimeout(() => { setLayout("chat"); }, 300); } else { router.push("/home"); } - }, [q]); + }, []); + return ( - <div> + <div className="h-full"> <AnimatePresence mode="popLayout"> {layout === "initial" ? ( <motion.div exit={{ opacity: 0 }} key="initial" - className="max-w-3xl flex mx-auto w-full flex-col" + className="max-w-3xl h-full justify-center items-center flex mx-auto w-full flex-col" > <div className="w-full h-96"> <QueryInput initialQuery={q} initialSpaces={[]} disabled /> @@ -36,16 +99,111 @@ function ChatWindow({ q }: { q: string }) { </motion.div> ) : ( <div - className="max-w-3xl flex mx-auto w-full flex-col mt-8" + className="max-w-3xl flex mx-auto w-full flex-col mt-24" key="chat" > - <h2 - className={cn( - "transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", - )} - > - {q} - </h2> + {chatHistory.map((chat, idx) => ( + <div + key={idx} + className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b" : ""}`} + > + <h2 + className={cn( + "text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", + )} + > + {chat.question} + </h2> + + <div className="flex flex-col gap-2 mt-2"> + <div + className={`${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`} + > + <Accordion + defaultValue={ + idx === chatHistory.length - 1 ? "memories" : "" + } + type="single" + collapsible + > + <AccordionItem value="memories"> + <AccordionTrigger className="text-foreground-menu"> + Related Memories + </AccordionTrigger> + {/* TODO: fade out content on the right side, the fade goes away when the user scrolls */} + <AccordionContent + className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" + defaultChecked + > + {/* Loading state */} + {chat.answer.sources.length > 0 || + (chat.answer.parts.length === 0 && ( + <> + {[1, 2, 3, 4].map((_, idx) => ( + <div + key={`loadingState-${idx}`} + className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72 animate-pulse" + > + <div className="bg-slate-700 h-2 rounded-full w-1/2"></div> + <div className="bg-slate-700 h-2 rounded-full w-full"></div> + </div> + ))} + </> + ))} + {chat.answer.sources.map((source, idx) => ( + <div + key={idx} + className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72" + > + <div className="text-foreground-menu"> + {source.type} + </div> + <div>{source.title}</div> + </div> + ))} + </AccordionContent> + </AccordionItem> + </Accordion> + </div> + + {/* Summary */} + <div> + <div className="text-foreground-menu py-2">Summary</div> + <div className="text-base"> + {chat.answer.parts.length === 0 && ( + <div className="animate-pulse flex space-x-4"> + <div className="flex-1 space-y-3 py-1"> + <div className="h-2 bg-slate-700 rounded"></div> + <div className="h-2 bg-slate-700 rounded"></div> + </div> + </div> + )} + <Markdown + remarkPlugins={[remarkGfm, [remarkMath]]} + rehypePlugins={[ + rehypeKatex, + [ + rehypeHighlight, + { + detect: true, + ignoreMissing: true, + subset: codeLanguageSubset, + }, + ], + ]} + components={{ + code: code as any, + p: p as any, + }} + className="flex flex-col gap-2" + > + {chat.answer.parts.map((part) => part.text).join("")} + </Markdown> + </div> + </div> + </div> + </div> + ))} </div> )} </AnimatePresence> diff --git a/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx new file mode 100644 index 00000000..747d4fca --- /dev/null +++ b/apps/web/app/(dash)/chat/markdownRenderHelpers.tsx @@ -0,0 +1,25 @@ +import { DetailedHTMLProps, HTMLAttributes, memo } from "react"; +import { ExtraProps } from "react-markdown"; +import CodeBlock from "./CodeBlock"; + +export const code = memo((props: JSX.IntrinsicElements["code"]) => { + const { className, children } = props; + const match = /language-(\w+)/.exec(className || ""); + const lang = match && match[1]; + + return <CodeBlock lang={lang || "text"} codeChildren={children as any} />; +}); + +export const p = memo( + ( + props?: Omit< + DetailedHTMLProps< + HTMLAttributes<HTMLParagraphElement>, + HTMLParagraphElement + >, + "ref" + >, + ) => { + return <p className="whitespace-pre-wrap">{props?.children}</p>; + }, +); diff --git a/apps/web/app/(dash)/chat/page.tsx b/apps/web/app/(dash)/chat/page.tsx index 9e28fda7..fd4de826 100644 --- a/apps/web/app/(dash)/chat/page.tsx +++ b/apps/web/app/(dash)/chat/page.tsx @@ -1,5 +1,7 @@ import ChatWindow from "./chatWindow"; import { chatSearchParamsCache } from "../../helpers/lib/searchParams"; +// @ts-expect-error +await import("katex/dist/katex.min.css"); function Page({ searchParams, @@ -10,7 +12,7 @@ function Page({ console.log(spaces); - return <ChatWindow q={q} />; + return <ChatWindow q={q} spaces={[]} />; } export default Page; diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx index b703d55a..31f76fda 100644 --- a/apps/web/app/(dash)/dynamicisland.tsx +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -9,6 +9,17 @@ import { motion } from "framer-motion"; import { Label } from "@repo/ui/shadcn/label"; import { Input } from "@repo/ui/shadcn/input"; import { Textarea } from "@repo/ui/shadcn/textarea"; +import { createSpace } from "../actions/doers"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/shadcn/select"; +import { Space } from "../actions/types"; +import { getSpaces } from "../actions/fetchers"; +import { toast } from "sonner"; export function DynamicIsland() { const { scrollYProgress } = useScroll(); @@ -80,7 +91,7 @@ function DynamicIslandContent() { {show ? ( <div onClick={() => setshow(!show)} - className="bg-[#1F2428] px-3 w-[2.23rem] overflow-hidden hover:w-[9.2rem] whitespace-nowrap py-2 rounded-3xl transition-[width] cursor-pointer" + className="bg-secondary px-3 w-[2.23rem] overflow-hidden hover:w-[9.2rem] whitespace-nowrap py-2 rounded-3xl transition-[width] cursor-pointer" > <div className="flex gap-4 items-center"> <Image src={AddIcon} alt="Add icon" /> @@ -99,7 +110,25 @@ function DynamicIslandContent() { const fakeitems = ["spaces", "page", "note"]; function ToolBar({ cancelfn }: { cancelfn: () => void }) { + const [spaces, setSpaces] = useState<Space[]>([]); + const [index, setIndex] = useState(0); + + useEffect(() => { + (async () => { + let spaces = await getSpaces(); + + if (!spaces.success || !spaces.data) { + toast.warning("Unable to get spaces", { + richColors: true, + }); + setSpaces([]); + return; + } + setSpaces(spaces.data); + })(); + }, []); + return ( <AnimatePresence mode="wait"> <motion.div @@ -120,7 +149,7 @@ function ToolBar({ cancelfn }: { cancelfn: () => void }) { }} className="flex flex-col items-center" > - <div className="bg-[#1F2428] py-[.35rem] px-[.6rem] rounded-2xl"> + <div className="bg-secondary py-[.35rem] px-[.6rem] rounded-2xl"> <HoverEffect items={fakeitems} index={index} @@ -130,9 +159,9 @@ function ToolBar({ cancelfn }: { cancelfn: () => void }) { {index === 0 ? ( <SpaceForm cancelfn={cancelfn} /> ) : index === 1 ? ( - <PageForm cancelfn={cancelfn} /> + <PageForm cancelfn={cancelfn} spaces={spaces} /> ) : ( - <NoteForm cancelfn={cancelfn} /> + <NoteForm cancelfn={cancelfn} spaces={spaces} /> )} </motion.div> </AnimatePresence> @@ -182,7 +211,10 @@ export const HoverEffect = ({ function SpaceForm({ cancelfn }: { cancelfn: () => void }) { return ( - <div className="bg-[#1F2428] px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> + <form + action={createSpace} + className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3" + > <div> <Label className="text-[#858B92]" htmlFor="name"> Name @@ -190,34 +222,55 @@ function SpaceForm({ cancelfn }: { cancelfn: () => void }) { <Input className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" id="name" + name="name" /> </div> <div className="flex justify-between"> <a className="text-blue-500" href=""> pull from store </a> - <div + {/* <div onClick={cancelfn} className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" > cancel - </div> + </div> */} + <button + type="submit" + className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer" + > + Submit + </button> </div> - </div> + </form> ); } -function PageForm({ cancelfn }: { cancelfn: () => void }) { +function PageForm({ + cancelfn, + spaces, +}: { + cancelfn: () => void; + spaces: Space[]; +}) { return ( - <div className="bg-[#1F2428] px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> + <div className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> <div> - <Label className="text-[#858B92]" htmlFor="name"> + <Label className="text-[#858B92]" htmlFor="space"> Space </Label> - <Input - className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" - id="name" - /> + <Select> + <SelectTrigger> + <SelectValue placeholder="Space" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </SelectContent> + </Select> </div> <div> <Label className="text-[#858B92]" htmlFor="name"> @@ -240,17 +293,31 @@ function PageForm({ cancelfn }: { cancelfn: () => void }) { ); } -function NoteForm({ cancelfn }: { cancelfn: () => void }) { +function NoteForm({ + cancelfn, + spaces, +}: { + cancelfn: () => void; + spaces: Space[]; +}) { return ( - <div className="bg-[#1F2428] px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> + <div className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3"> <div> <Label className="text-[#858B92]" htmlFor="name"> Space </Label> - <Input - className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0" - id="name" - /> + <Select> + <SelectTrigger> + <SelectValue placeholder="Space" /> + </SelectTrigger> + <SelectContent className="bg-secondary text-white"> + {spaces.map((space) => ( + <SelectItem key={space.id} value={space.id.toString()}> + {space.name} + </SelectItem> + ))} + </SelectContent> + </Select> </div> <div> <Label className="text-[#858B92]" htmlFor="name"> diff --git a/apps/web/app/(dash)/header.tsx b/apps/web/app/(dash)/header.tsx index c5aeca3b..026cb080 100644 --- a/apps/web/app/(dash)/header.tsx +++ b/apps/web/app/(dash)/header.tsx @@ -9,7 +9,7 @@ import DynamicIsland from "./dynamicisland"; function Header() { return ( <div> - <div className="absolute left-0 w-full flex items-center justify-between z-10"> + <div className="fixed left-0 w-full flex items-center justify-between z-10"> <Link className="px-5" href="/home"> <Image src={Logo} diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index 0c75e457..b4bafb38 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -3,7 +3,7 @@ import Menu from "../menu"; import Header from "../header"; import QueryInput from "./queryinput"; import { homeSearchParamsCache } from "@/app/helpers/lib/searchParams"; -import { getSpaces } from "../actions"; +import { getSpaces } from "@/app/actions/fetchers"; async function Page({ searchParams, @@ -13,7 +13,12 @@ async function Page({ // TODO: use this to show a welcome page/modal const { firstTime } = homeSearchParamsCache.parse(searchParams); - const spaces = await getSpaces(); + let spaces = await getSpaces(); + + if (!spaces.success) { + // TODO: handle this error properly. + spaces.data = []; + } return ( <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col"> @@ -21,7 +26,7 @@ async function Page({ {/* <div className="">hi {firstTime ? 'first time' : ''}</div> */} <div className="w-full h-96"> - <QueryInput initialSpaces={spaces} /> + <QueryInput initialSpaces={spaces.data} /> </div> </div> ); diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index 4cb1fdb2..d0c27b8d 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -2,10 +2,11 @@ import { ArrowRightIcon } from "@repo/ui/icons"; import Image from "next/image"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Divider from "@repo/ui/shadcn/divider"; import { MultipleSelector, Option } from "@repo/ui/shadcn/combobox"; import { useRouter } from "next/navigation"; +import { getSpaces } from "@/app/actions/fetchers"; function QueryInput({ initialQuery = "", @@ -13,7 +14,10 @@ function QueryInput({ disabled = false, }: { initialQuery?: string; - initialSpaces?: { user: string | null; id: number; name: string }[]; + initialSpaces?: { + id: number; + name: string; + }[]; disabled?: boolean; }) { const [q, setQ] = useState(initialQuery); @@ -41,10 +45,14 @@ function QueryInput({ return newQ; }; - const options = initialSpaces.map((x) => ({ - label: x.name, - value: x.id.toString(), - })); + const options = useMemo( + () => + initialSpaces.map((x) => ({ + label: x.name, + value: x.id.toString(), + })), + [initialSpaces], + ); return ( <div> @@ -82,6 +90,7 @@ function QueryInput({ {/* selected sources */} <div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-[24px]"> <MultipleSelector + key={options.length} disabled={disabled} defaultOptions={options} onChange={(e) => setSelectedSpaces(e.map((x) => parseInt(x.value)))} diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index 85f8476e..b879a2f5 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -1,10 +1,11 @@ import Header from "./header"; import Menu from "./menu"; -import { ensureAuth } from "./actions"; import { redirect } from "next/navigation"; +import { auth } from "../helpers/server/auth"; +import { Toaster } from "@repo/ui/shadcn/sonner"; async function Layout({ children }: { children: React.ReactNode }) { - const info = await ensureAuth(); + const info = await auth(); if (!info) { return redirect("/signin"); @@ -17,6 +18,8 @@ async function Layout({ children }: { children: React.ReactNode }) { <Menu /> {children} + + <Toaster /> </main> ); } diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index dfd60b96..5f26f545 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -23,7 +23,7 @@ function Menu() { ]; return ( - <div className="absolute h-screen pb-[25vh] w-full p-4 flex items-end justify-end lg:justify-start lg:items-center top-0 left-0 pointer-events-none"> + <div className="fixed h-screen pb-[25vh] w-full p-4 flex items-end justify-end lg:justify-start lg:items-center top-0 left-0 pointer-events-none"> <div className=""> <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] bg-secondary px-3 py-4 duration-200 hover:w-40"> {menuItems.map((item) => ( diff --git a/apps/web/app/(landing)/package.json b/apps/web/app/(landing)/package.json deleted file mode 100644 index a7fabf2f..00000000 --- a/apps/web/app/(landing)/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "scripts": { - "vercel-build": "next build" - } -} diff --git a/apps/web/app/(landing)/page.tsx b/apps/web/app/(landing)/page.tsx index 562e9af9..09f94d92 100644 --- a/apps/web/app/(landing)/page.tsx +++ b/apps/web/app/(landing)/page.tsx @@ -16,7 +16,7 @@ export default async function Home() { console.log(user); if (user) { - // await redirect("/home") + await redirect("/home"); } return ( diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts new file mode 100644 index 00000000..c8a1f3b4 --- /dev/null +++ b/apps/web/app/actions/doers.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "../helpers/server/db"; +import { space } from "../helpers/server/db/schema"; +import { ServerActionReturnType } from "./types"; +import { auth } from "../helpers/server/auth"; + +export const createSpace = async ( + input: string | FormData, +): ServerActionReturnType<number> => { + const data = await auth(); + + if (!data || !data.user) { + return { error: "Not authenticated", success: false }; + } + + if (typeof input === "object") { + input = (input as FormData).get("name") as string; + } + + try { + const resp = await db + .insert(space) + .values({ name: input, user: data.user.id }); + + revalidatePath("/home"); + return { success: true, data: 1 }; + } catch (e: unknown) { + const error = e as Error; + if ( + error.message.includes("D1_ERROR: UNIQUE constraint failed: space.name") + ) { + return { success: false, data: 0, error: "Space already exists" }; + } else { + return { + success: false, + data: 0, + error: "Failed to create space with error: " + error.message, + }; + } + } +}; diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts new file mode 100644 index 00000000..9c2527f0 --- /dev/null +++ b/apps/web/app/actions/fetchers.ts @@ -0,0 +1,25 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { db } from "../helpers/server/db"; +import { users } from "../helpers/server/db/schema"; +import { ServerActionReturnType, Space } from "./types"; +import { auth } from "../helpers/server/auth"; + +export const getSpaces = async (): ServerActionReturnType<Space[]> => { + const data = await auth(); + + if (!data || !data.user) { + return { error: "Not authenticated", success: false }; + } + + const spaces = await db.query.space.findMany({ + where: eq(users, data.user.id), + }); + + const spacesWithoutUser = spaces.map((space) => { + return { ...space, user: undefined }; + }); + + return { success: true, data: spacesWithoutUser }; +}; diff --git a/apps/web/app/actions/types.ts b/apps/web/app/actions/types.ts new file mode 100644 index 00000000..fbf669e2 --- /dev/null +++ b/apps/web/app/actions/types.ts @@ -0,0 +1,10 @@ +export type Space = { + id: number; + name: string; +}; + +export type ServerActionReturnType<T> = Promise<{ + error?: string; + success: boolean; + data?: T; +}>; diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 34099848..aba8784c 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,6 +1,11 @@ import { type NextRequest } from "next/server"; -import { ChatHistory } from "@repo/shared-types"; +import { + ChatHistory, + ChatHistoryZod, + convertChatHistoryList, +} from "@repo/shared-types"; import { ensureAuth } from "../ensureAuth"; +import { z } from "zod"; export const runtime = "edge"; @@ -15,59 +20,69 @@ export async function POST(req: NextRequest) { return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 }); } - const query = new URL(req.url).searchParams.get("q"); - const spaces = new URL(req.url).searchParams.get("spaces"); + const url = new URL(req.url); - const sourcesOnly = - new URL(req.url).searchParams.get("sourcesOnly") ?? "false"; + const query = url.searchParams.get("q"); + const spaces = url.searchParams.get("spaces"); - const chatHistory = (await req.json()) as { - chatHistory: ChatHistory[]; - }; + const sourcesOnly = url.searchParams.get("sourcesOnly") ?? "false"; - console.log("CHathistory", chatHistory); + const chatHistory = await req.json(); - if (!query) { + if (!query || query.trim.length < 0) { return new Response(JSON.stringify({ message: "Invalid query" }), { status: 400, }); } - try { - const resp = await fetch( - `https://cf-ai-backend.dhravya.workers.dev/chat?q=${query}&user=${session.user.email ?? session.user.name}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, - { - headers: { - "X-Custom-Auth-Key": process.env.BACKEND_SECURITY_KEY!, - }, - method: "POST", - body: JSON.stringify({ - chatHistory: chatHistory.chatHistory ?? [], - }), + const validated = z + .object({ chatHistory: z.array(ChatHistoryZod) }) + .safeParse(chatHistory ?? []); + + if (!validated.success) { + return new Response( + JSON.stringify({ + message: "Invalid chat history", + error: validated.error, + }), + { status: 400 }, + ); + } + + const modelCompatible = await convertChatHistoryList( + validated.data.chatHistory, + ); + + const resp = await fetch( + `https://new-cf-ai-backend.dhravya.workers.dev/api/chat?query=${query}&user=${session.user.email}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`, + "Content-Type": "application/json", }, + method: "POST", + body: JSON.stringify({ + chatHistory: modelCompatible, + }), + }, + ); + + console.log("sourcesOnly", sourcesOnly); + + if (sourcesOnly == "true") { + const data = await resp.json(); + console.log("data", data); + return new Response(JSON.stringify(data), { status: 200 }); + } + + if (resp.status !== 200 || !resp.ok) { + const errorData = await resp.text(); + console.log(errorData); + return new Response( + JSON.stringify({ message: "Error in CF function", error: errorData }), + { status: resp.status }, ); + } - console.log("sourcesOnly", sourcesOnly); - - if (sourcesOnly == "true") { - const data = await resp.json(); - console.log("data", data); - return new Response(JSON.stringify(data), { status: 200 }); - } - - if (resp.status !== 200 || !resp.ok) { - const errorData = await resp.json(); - console.log(errorData); - return new Response( - JSON.stringify({ message: "Error in CF function", error: errorData }), - { status: resp.status }, - ); - } - - // Stream the response back to the client - const { readable, writable } = new TransformStream(); - resp && resp.body!.pipeTo(writable); - - return new Response(readable, { status: 200 }); - } catch {} + return new Response(resp.body, { status: 200 }); } diff --git a/apps/web/app/helpers/constants.ts b/apps/web/app/helpers/constants.ts new file mode 100644 index 00000000..c3fc640a --- /dev/null +++ b/apps/web/app/helpers/constants.ts @@ -0,0 +1,37 @@ +export const codeLanguageSubset = [ + "python", + "javascript", + "java", + "go", + "bash", + "c", + "cpp", + "csharp", + "css", + "diff", + "graphql", + "json", + "kotlin", + "less", + "lua", + "makefile", + "markdown", + "objectivec", + "perl", + "php", + "php-template", + "plaintext", + "python-repl", + "r", + "ruby", + "rust", + "scss", + "shell", + "sql", + "swift", + "typescript", + "vbnet", + "wasm", + "xml", + "yaml", +]; diff --git a/apps/web/app/helpers/server/auth.ts b/apps/web/app/helpers/server/auth.ts index 73119d87..c4e426d4 100644 --- a/apps/web/app/helpers/server/auth.ts +++ b/apps/web/app/helpers/server/auth.ts @@ -2,6 +2,7 @@ import NextAuth, { NextAuthResult } from "next-auth"; import Google from "next-auth/providers/google"; import { DrizzleAdapter } from "@auth/drizzle-adapter"; import { db } from "./db"; +import { accounts, sessions, users, verificationTokens } from "./db/schema"; export const { handlers: { GET, POST }, @@ -10,16 +11,20 @@ export const { auth, } = NextAuth({ secret: process.env.BACKEND_SECURITY_KEY, - callbacks: { - session: ({ session, token, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - }, - }), - }, - adapter: DrizzleAdapter(db), + // callbacks: { + // session: ({ session, token, user }) => ({ + // ...session, + // user: { + // ...session.user, + // }, + // }), + // }, + adapter: DrizzleAdapter(db, { + usersTable: users, + accountsTable: accounts, + sessionsTable: sessions, + verificationTokensTable: verificationTokens, + }), providers: [ Google({ clientId: process.env.GOOGLE_CLIENT_ID, diff --git a/apps/web/app/helpers/server/db/schema.ts b/apps/web/app/helpers/server/db/schema.ts index c4616eb2..e3e789c6 100644 --- a/apps/web/app/helpers/server/db/schema.ts +++ b/apps/web/app/helpers/server/db/schema.ts @@ -7,75 +7,88 @@ import { text, integer, } from "drizzle-orm/sqlite-core"; +import type { AdapterAccountType } from "next-auth/adapters"; export const createTable = sqliteTableCreator((name) => `${name}`); export const users = createTable("user", { - id: text("id", { length: 255 }).notNull().primaryKey(), - name: text("name", { length: 255 }), - email: text("email", { length: 255 }).notNull(), - emailVerified: int("emailVerified", { mode: "timestamp" }).default( - sql`CURRENT_TIMESTAMP`, - ), - image: text("image", { length: 255 }), + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + name: text("name"), + email: text("email").notNull(), + emailVerified: integer("emailVerified", { mode: "timestamp_ms" }), + image: text("image"), }); export type User = typeof users.$inferSelect; -export const usersRelations = relations(users, ({ many }) => ({ - accounts: many(accounts), - sessions: many(sessions), -})); - export const accounts = createTable( "account", { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - userId: text("userId", { length: 255 }) + userId: text("userId") .notNull() .references(() => users.id, { onDelete: "cascade" }), - type: text("type", { length: 255 }).notNull(), - provider: text("provider", { length: 255 }).notNull(), - providerAccountId: text("providerAccountId", { length: 255 }).notNull(), + type: text("type").$type<AdapterAccountType>().notNull(), + provider: text("provider").notNull(), + providerAccountId: text("providerAccountId").notNull(), refresh_token: text("refresh_token"), access_token: text("access_token"), - expires_at: int("expires_at"), - token_type: text("token_type", { length: 255 }), - scope: text("scope", { length: 255 }), + expires_at: integer("expires_at"), + token_type: text("token_type"), + scope: text("scope"), id_token: text("id_token"), - session_state: text("session_state", { length: 255 }), - oauth_token_secret: text("oauth_token_secret"), - oauth_token: text("oauth_token"), + session_state: text("session_state"), }, (account) => ({ - userIdIdx: index("account_userId_idx").on(account.userId), + compoundKey: primaryKey({ + columns: [account.provider, account.providerAccountId], + }), }), ); -export const sessions = createTable( - "session", +export const sessions = createTable("session", { + sessionToken: text("sessionToken").primaryKey(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: integer("expires", { mode: "timestamp_ms" }).notNull(), +}); + +export const verificationTokens = createTable( + "verificationToken", { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - sessionToken: text("sessionToken", { length: 255 }).notNull(), - userId: text("userId", { length: 255 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - expires: int("expires", { mode: "timestamp" }).notNull(), + identifier: text("identifier").notNull(), + token: text("token").notNull(), + expires: integer("expires", { mode: "timestamp_ms" }).notNull(), }, - (session) => ({ - userIdIdx: index("session_userId_idx").on(session.userId), + (verificationToken) => ({ + compositePk: primaryKey({ + columns: [verificationToken.identifier, verificationToken.token], + }), }), ); -export const verificationTokens = createTable( - "verificationToken", +export const authenticators = createTable( + "authenticator", { - identifier: text("identifier", { length: 255 }).notNull(), - token: text("token", { length: 255 }).notNull(), - expires: int("expires", { mode: "timestamp" }).notNull(), + credentialID: text("credentialID").notNull().unique(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + providerAccountId: text("providerAccountId").notNull(), + credentialPublicKey: text("credentialPublicKey").notNull(), + counter: integer("counter").notNull(), + credentialDeviceType: text("credentialDeviceType").notNull(), + credentialBackedUp: integer("credentialBackedUp", { + mode: "boolean", + }).notNull(), + transports: text("transports"), }, - (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), + (authenticator) => ({ + compositePK: primaryKey({ + columns: [authenticator.userId, authenticator.credentialID], + }), }), ); @@ -94,7 +107,7 @@ export const storedContent = createTable( "page", ), image: text("image", { length: 255 }), - user: text("user", { length: 255 }).references(() => users.id, { + userId: int("user").references(() => users.id, { onDelete: "cascade", }), }, @@ -102,7 +115,7 @@ export const storedContent = createTable( urlIdx: index("storedContent_url_idx").on(sc.url), savedAtIdx: index("storedContent_savedAt_idx").on(sc.savedAt), titleInx: index("storedContent_title_idx").on(sc.title), - userIdx: index("storedContent_user_idx").on(sc.user), + userIdx: index("storedContent_user_idx").on(sc.userId), }), ); diff --git a/apps/web/migrations/000_setup.sql b/apps/web/migrations/000_setup.sql index db7f9444..0c151b98 100644 --- a/apps/web/migrations/000_setup.sql +++ b/apps/web/migrations/000_setup.sql @@ -1,18 +1,29 @@ CREATE TABLE `account` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `userId` text(255) NOT NULL, - `type` text(255) NOT NULL, - `provider` text(255) NOT NULL, - `providerAccountId` text(255) NOT NULL, + `userId` text NOT NULL, + `type` text NOT NULL, + `provider` text NOT NULL, + `providerAccountId` text NOT NULL, `refresh_token` text, `access_token` text, `expires_at` integer, - `token_type` text(255), - `scope` text(255), + `token_type` text, + `scope` text, `id_token` text, - `session_state` text(255), - `oauth_token_secret` text, - `oauth_token` text, + `session_state` text, + PRIMARY KEY(`provider`, `providerAccountId`), + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `authenticator` ( + `credentialID` text NOT NULL, + `userId` text NOT NULL, + `providerAccountId` text NOT NULL, + `credentialPublicKey` text NOT NULL, + `counter` integer NOT NULL, + `credentialDeviceType` text NOT NULL, + `credentialBackedUp` integer NOT NULL, + `transports` text, + PRIMARY KEY(`credentialID`, `userId`), FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint @@ -25,9 +36,8 @@ CREATE TABLE `contentToSpace` ( ); --> statement-breakpoint CREATE TABLE `session` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `sessionToken` text(255) NOT NULL, - `userId` text(255) NOT NULL, + `sessionToken` text PRIMARY KEY NOT NULL, + `userId` text NOT NULL, `expires` integer NOT NULL, FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); @@ -50,27 +60,26 @@ CREATE TABLE `storedContent` ( `ogImage` text(255), `type` text DEFAULT 'page', `image` text(255), - `user` text(255), + `user` integer, FOREIGN KEY (`user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `user` ( - `id` text(255) PRIMARY KEY NOT NULL, - `name` text(255), - `email` text(255) NOT NULL, - `emailVerified` integer DEFAULT CURRENT_TIMESTAMP, - `image` text(255) + `id` text PRIMARY KEY NOT NULL, + `name` text, + `email` text NOT NULL, + `emailVerified` integer, + `image` text ); --> statement-breakpoint CREATE TABLE `verificationToken` ( - `identifier` text(255) NOT NULL, - `token` text(255) NOT NULL, + `identifier` text NOT NULL, + `token` text NOT NULL, `expires` integer NOT NULL, PRIMARY KEY(`identifier`, `token`) ); --> statement-breakpoint -CREATE INDEX `account_userId_idx` ON `account` (`userId`);--> statement-breakpoint -CREATE INDEX `session_userId_idx` ON `session` (`userId`);--> statement-breakpoint +CREATE UNIQUE INDEX `authenticator_credentialID_unique` ON `authenticator` (`credentialID`);--> statement-breakpoint CREATE UNIQUE INDEX `space_name_unique` ON `space` (`name`);--> statement-breakpoint CREATE INDEX `spaces_name_idx` ON `space` (`name`);--> statement-breakpoint CREATE INDEX `spaces_user_idx` ON `space` (`user`);--> statement-breakpoint diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index 29cc4323..20327dda 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,43 +1,36 @@ { "version": "6", "dialect": "sqlite", - "id": "409cec60-0c4b-4cda-8751-3e70768bbb6c", + "id": "4a568d9b-a0e6-44ed-946b-694e34b063f3", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { "name": "account", "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, "userId": { "name": "userId", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "type": { "name": "type", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "provider": { "name": "provider", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "providerAccountId": { "name": "providerAccountId", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false @@ -65,14 +58,14 @@ }, "token_type": { "name": "token_type", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, "scope": { "name": "scope", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false @@ -86,20 +79,86 @@ }, "session_state": { "name": "session_state", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": ["provider", "providerAccountId"], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "authenticator": { + "name": "authenticator", + "columns": { + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false }, - "oauth_token_secret": { - "name": "oauth_token_secret", + "userId": { + "name": "userId", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "oauth_token": { - "name": "oauth_token", + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialPublicKey": { + "name": "credentialPublicKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialDeviceType": { + "name": "credentialDeviceType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentialBackedUp": { + "name": "credentialBackedUp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", "type": "text", "primaryKey": false, "notNull": false, @@ -107,16 +166,16 @@ } }, "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": ["userId"], - "isUnique": false + "authenticator_credentialID_unique": { + "name": "authenticator_credentialID_unique", + "columns": ["credentialID"], + "isUnique": true } }, "foreignKeys": { - "account_userId_user_id_fk": { - "name": "account_userId_user_id_fk", - "tableFrom": "account", + "authenticator_userId_user_id_fk": { + "name": "authenticator_userId_user_id_fk", + "tableFrom": "authenticator", "tableTo": "user", "columnsFrom": ["userId"], "columnsTo": ["id"], @@ -124,7 +183,12 @@ "onUpdate": "no action" } }, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "authenticator_userId_credentialID_pk": { + "columns": ["credentialID", "userId"], + "name": "authenticator_userId_credentialID_pk" + } + }, "uniqueConstraints": {} }, "contentToSpace": { @@ -177,23 +241,16 @@ "session": { "name": "session", "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, "sessionToken": { "name": "sessionToken", - "type": "text(255)", - "primaryKey": false, + "type": "text", + "primaryKey": true, "notNull": true, "autoincrement": false }, "userId": { "name": "userId", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false @@ -206,13 +263,7 @@ "autoincrement": false } }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": ["userId"], - "isUnique": false - } - }, + "indexes": {}, "foreignKeys": { "session_userId_user_id_fk": { "name": "session_userId_user_id_fk", @@ -360,7 +411,7 @@ }, "user": { "name": "user", - "type": "text(255)", + "type": "integer", "primaryKey": false, "notNull": false, "autoincrement": false @@ -407,21 +458,21 @@ "columns": { "id": { "name": "id", - "type": "text(255)", + "type": "text", "primaryKey": true, "notNull": true, "autoincrement": false }, "name": { "name": "name", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, "email": { "name": "email", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false @@ -431,12 +482,11 @@ "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false, - "default": "CURRENT_TIMESTAMP" + "autoincrement": false }, "image": { "name": "image", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false @@ -452,14 +502,14 @@ "columns": { "identifier": { "name": "identifier", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "token": { "name": "token", - "type": "text(255)", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index a77d9616..90bb9df7 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1716677954608, - "tag": "0000_calm_monster_badoon", + "when": 1718412145023, + "tag": "0000_absurd_pandemic", "breakpoints": true } ] diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 51c492f4..cc820fef 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -4,6 +4,7 @@ import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev"; /** @type {import('next').NextConfig} */ const nextConfig = { transpilePackages: ["@repo/ui"], + reactStrictMode: false, }; export default MillionLint.next({ rsc: true, diff --git a/apps/web/package.json b/apps/web/package.json index 324f5655..19f77187 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,7 +11,8 @@ "pages:build": "bunx @cloudflare/next-on-pages", "preview": "bun pages:build && wrangler pages dev", "deploy": "bun pages:build && wrangler pages deploy", - "schema-update": "bunx drizzle-orm" + "schema-update": "bunx drizzle-kit generate sqlite", + "update-local-db": "bunx wrangler d1 execute dev-d1-anycontext --local" }, "dependencies": { "@million/lint": "^1.0.0-rc.11", diff --git a/package.json b/package.json index 78ec76ec..11e3ba06 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "readline-sync": "^1.4.10", "tailwindcss": "^3.4.3", "tailwindcss-animate": "^1.0.7", - "turbo": "latest", + "turbo": "2.0.3", "vercel": "^34.2.0" }, "engines": { @@ -52,10 +52,12 @@ "@iarna/toml": "^2.2.5", "@langchain/cloudflare": "^0.0.6", "@million/lint": "^1.0.0-rc.18", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", @@ -67,13 +69,21 @@ "compromise": "^14.13.0", "drizzle-orm": "^0.30.10", "framer-motion": "^11.2.6", + "katex": "^0.16.10", "lucide-react": "^0.379.0", "next-app-theme": "^0.1.10", "next-auth": "^5.0.0-beta.18", + "next-themes": "^0.3.0", "random-js": "^2.1.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.51.5", - "sonner": "^1.4.41", + "react-markdown": "^9.0.1", + "rehype-highlight": "^7.0.0", + "rehype-katex": "^7.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "sonner": "^1.5.0", + "tailwind-scrollbar": "^3.1.0", "tldraw": "^2.1.4", "uploadthing": "^6.10.4", "zod": "^3.23.8" diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index bf4a56da..46e3edba 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -1,7 +1,54 @@ -export type ChatHistory = { - question: string; - answer: { - parts: { text: string }[]; - sources: { isNote: boolean; source: string }[]; - }; -}; +import { z } from "zod"; + +export const ChatHistoryZod = z.object({ + question: z.string(), + answer: z.object({ + parts: z.array(z.object({ text: z.string() })), + sources: z.array( + z.object({ + type: z.enum(["note", "page", "tweet"]), + source: z.string(), + title: z.string(), + content: z.string(), + }), + ), + }), +}); + +export type ChatHistory = z.infer<typeof ChatHistoryZod>; + +export const ModelCompatibleChatHistoryZod = z.array( + z.object({ + role: z.union([ + z.literal("user"), + z.literal("assistant"), + z.literal("system"), + ]), + content: z.string(), + }), +); + +export type ModelCompatibleChatHistory = z.infer< + typeof ModelCompatibleChatHistoryZod +>; + +export function convertChatHistoryList( + chatHistoryList: ChatHistory[], +): ModelCompatibleChatHistory { + let convertedChats: ModelCompatibleChatHistory = []; + + chatHistoryList.forEach((chat) => { + convertedChats.push( + { + role: "user", + content: chat.question, + }, + { + role: "assistant", + content: chat.answer.parts.map((part) => part.text).join(" "), + }, + ); + }); + + return convertedChats; +} diff --git a/packages/tailwind-config/globals.css b/packages/tailwind-config/globals.css index 18017f73..d845aca4 100644 --- a/packages/tailwind-config/globals.css +++ b/packages/tailwind-config/globals.css @@ -49,6 +49,42 @@ body { align-items: center; justify-content: center; } + + .markdown table { + --tw-border-spacing-x: 0px; + --tw-border-spacing-y: 0px; + border-collapse: separate; + border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y); + width: 100%; + } + .markdown th { + background-color: rgba(236, 236, 241, 0.2); + border-bottom-width: 1px; + border-left-width: 1px; + border-top-width: 1px; + padding: 0.25rem 0.75rem; + } + .markdown th:first-child { + border-top-left-radius: 0.375rem; + } + .markdown th:last-child { + border-right-width: 1px; + border-top-right-radius: 0.375rem; + } + .markdown td { + border-bottom-width: 1px; + border-left-width: 1px; + padding: 0.25rem 0.75rem; + } + .markdown td:last-child { + border-right-width: 1px; + } + .markdown tbody tr:last-child td:first-child { + border-bottom-left-radius: 0.375rem; + } + .markdown tbody tr:last-child td:last-child { + border-bottom-right-radius: 0.375rem; + } } @layer utilities { @@ -57,6 +93,34 @@ body { } } +@layer components { + .markdown ol, + .markdown ul { + display: flex; + flex-direction: column; + padding-left: 1rem; + } + + .markdown ol li, + .markdown ol li > p, + .markdown ol ol, + .markdown ol ul, + .markdown ul li, + .markdown ul li > p, + .markdown ul ol, + .markdown ul ul { + margin: 0; + } + + .markdown ul li:before { + content: "•"; + font-size: 0.875rem; + line-height: 1.25rem; + margin-left: -1rem; + position: absolute; + } +} + .gradient-background { background: linear-gradient( 150deg, @@ -84,3 +148,62 @@ body { ::-webkit-scrollbar-thumb:hover { background: #22303d; } + +.no-scrollbar { + /* For WebKit (Safari, Chrome, etc.) */ + &::-webkit-scrollbar { + display: none; + } + + /* For Firefox */ + scrollbar-width: none; + + /* For IE and Edge */ + -ms-overflow-style: none; +} + +:not(pre) > code.hljs, +:not(pre) > code[class*="language-"] { + border-radius: 0.3em; + white-space: normal; +} +.hljs-comment { + color: hsla(0, 0%, 100%, 0.5); +} +.hljs-meta { + color: hsla(0, 0%, 100%, 0.6); +} +.hljs-built_in, +.hljs-class .hljs-title { + color: #e9950c; +} +.hljs-doctag, +.hljs-formula, +.hljs-keyword, +.hljs-literal { + color: #2e95d3; +} +.hljs-addition, +.hljs-attribute, +.hljs-meta-string, +.hljs-regexp, +.hljs-string { + color: #00a67d; +} +.hljs-attr, +.hljs-number, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-pseudo, +.hljs-template-variable, +.hljs-type, +.hljs-variable { + color: #df3079; +} +.hljs-bullet, +.hljs-link, +.hljs-selector-id, +.hljs-symbol, +.hljs-title { + color: #f22c3d; +} diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts index bf36b528..3711bd41 100644 --- a/packages/tailwind-config/tailwind.config.ts +++ b/packages/tailwind-config/tailwind.config.ts @@ -71,7 +71,11 @@ const config = { }, }, }, - plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], + plugins: [ + require("tailwindcss-animate"), + require("@tailwindcss/typography"), + require("tailwind-scrollbar"), + ], } satisfies Config; export default config; diff --git a/packages/ui/shadcn/accordion.tsx b/packages/ui/shadcn/accordion.tsx new file mode 100644 index 00000000..a5dedb19 --- /dev/null +++ b/packages/ui/shadcn/accordion.tsx @@ -0,0 +1,54 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> +>(({ className, ...props }, ref) => ( + <AccordionPrimitive.Item ref={ref} className={cn(className)} {...props} /> +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center gap-2 py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", + className, + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> +)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/shadcn/select.tsx b/packages/ui/shadcn/select.tsx new file mode 100644 index 00000000..8abe27c1 --- /dev/null +++ b/packages/ui/shadcn/select.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@repo/ui/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className, + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className, + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-foreground-menu data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className, + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/packages/ui/shadcn/sonner.tsx b/packages/ui/shadcn/sonner.tsx new file mode 100644 index 00000000..549cf841 --- /dev/null +++ b/packages/ui/shadcn/sonner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; |