aboutsummaryrefslogtreecommitdiff
path: root/apps/web/app/actions
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2024-06-18 17:58:46 -0500
committerDhravya Shah <[email protected]>2024-06-18 17:58:46 -0500
commitf4bb71e8f7e07bb2e919b7f222d5acb2905eb8f2 (patch)
tree7310dc521ef3559055bbe71f50c3861be2fa0503 /apps/web/app/actions
parentdarkmode by default - so that the colors don't f up on lightmode devices (diff)
parentCreate Embeddings for Canvas (diff)
downloadsupermemory-default-darkmode.tar.xz
supermemory-default-darkmode.zip
Diffstat (limited to 'apps/web/app/actions')
-rw-r--r--apps/web/app/actions/doers.ts268
-rw-r--r--apps/web/app/actions/fetchers.ts142
-rw-r--r--apps/web/app/actions/types.ts11
3 files changed, 421 insertions, 0 deletions
diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts
new file mode 100644
index 00000000..6c7180d9
--- /dev/null
+++ b/apps/web/app/actions/doers.ts
@@ -0,0 +1,268 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { db } from "../../server/db";
+import { contentToSpace, space, storedContent } from "../../server/db/schema";
+import { ServerActionReturnType } from "./types";
+import { auth } from "../../server/auth";
+import { Tweet } from "react-tweet/api";
+import { getMetaData } from "@/lib/get-metadata";
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { LIMITS } from "@/lib/constants";
+import { z } from "zod";
+
+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,
+ };
+ }
+ }
+};
+
+const typeDecider = (content: string) => {
+ // if the content is a URL, then it's a page. if its a URL with https://x.com/user/status/123, then it's a tweet. else, it's a note.
+ // do strict checking with regex
+ if (content.match(/https?:\/\/[\w\.]+\/[\w]+\/[\w]+\/[\d]+/)) {
+ return "tweet";
+ } else if (content.match(/https?:\/\/[\w\.]+/)) {
+ return "page";
+ } else {
+ return "note";
+ }
+};
+
+export const limit = async (userId: string, type = "page") => {
+ const count = await db
+ .select({
+ count: sql<number>`count(*)`.mapWith(Number),
+ })
+ .from(storedContent)
+ .where(and(eq(storedContent.userId, userId), eq(storedContent.type, type)));
+
+ if (count[0]!.count > LIMITS[type as keyof typeof LIMITS]) {
+ return false;
+ }
+
+ return true;
+};
+
+const getTweetData = async (tweetID: string) => {
+ const url = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetID}&lang=en&features=tfw_timeline_list%3A%3Btfw_follower_count_sunset%3Atrue%3Btfw_tweet_edit_backend%3Aon%3Btfw_refsrc_session%3Aon%3Btfw_fosnr_soft_interventions_enabled%3Aon%3Btfw_show_birdwatch_pivots_enabled%3Aon%3Btfw_show_business_verified_badge%3Aon%3Btfw_duplicate_scribes_to_settings%3Aon%3Btfw_use_profile_image_shape_enabled%3Aon%3Btfw_show_blue_verified_badge%3Aon%3Btfw_legacy_timeline_sunset%3Atrue%3Btfw_show_gov_verified_badge%3Aon%3Btfw_show_business_affiliate_badge%3Aon%3Btfw_tweet_edit_frontend%3Aon&token=4c2mmul6mnh`;
+
+ const resp = await fetch(url, {
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
+ Accept: "application/json",
+ "Accept-Language": "en-US,en;q=0.5",
+ "Accept-Encoding": "gzip, deflate, br",
+ Connection: "keep-alive",
+ "Upgrade-Insecure-Requests": "1",
+ "Cache-Control": "max-age=0",
+ TE: "Trailers",
+ },
+ });
+ console.log(resp.status);
+ const data = (await resp.json()) as Tweet;
+
+ return data;
+};
+
+export const createMemory = async (input: {
+ content: string;
+ spaces?: string[];
+}): ServerActionReturnType<number> => {
+ const data = await auth();
+
+ if (!data || !data.user || !data.user.id) {
+ return { error: "Not authenticated", success: false };
+ }
+
+ const type = typeDecider(input.content);
+
+ let pageContent = input.content;
+ let metadata: Awaited<ReturnType<typeof getMetaData>>;
+
+ if (!(await limit(data.user.id, type))) {
+ return {
+ success: false,
+ data: 0,
+ error: `You have exceeded the limit of ${LIMITS[type as keyof typeof LIMITS]} ${type}s.`,
+ };
+ }
+
+ if (type === "page") {
+ const response = await fetch("https://md.dhr.wtf/?url=" + input.content, {
+ headers: {
+ Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY,
+ },
+ });
+ pageContent = await response.text();
+ metadata = await getMetaData(input.content);
+ } else if (type === "tweet") {
+ const tweet = await getTweetData(input.content.split("/").pop() as string);
+ pageContent = JSON.stringify(tweet);
+ metadata = {
+ baseUrl: input.content,
+ description: tweet.text,
+ image: tweet.user.profile_image_url_https,
+ title: `Tweet by ${tweet.user.name}`,
+ };
+ } else if (type === "note") {
+ pageContent = input.content;
+ const noteId = new Date().getTime();
+ metadata = {
+ baseUrl: `https://supermemory.ai/note/${noteId}`,
+ description: `Note created at ${new Date().toLocaleString()}`,
+ image: "https://supermemory.ai/logo.png",
+ title: `${pageContent.slice(0, 20)} ${pageContent.length > 20 ? "..." : ""}`,
+ };
+ } else {
+ return {
+ success: false,
+ data: 0,
+ error: "Invalid type",
+ };
+ }
+
+ let storeToSpaces = input.spaces;
+
+ if (!storeToSpaces) {
+ storeToSpaces = [];
+ }
+
+ const vectorSaveResponse = await fetch(
+ `${process.env.BACKEND_BASE_URL}/api/add`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ pageContent,
+ title: metadata.title,
+ description: metadata.description,
+ url: metadata.baseUrl,
+ spaces: storeToSpaces,
+ user: data.user.id,
+ type,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY,
+ },
+ },
+ );
+
+ if (!vectorSaveResponse.ok) {
+ const errorData = await vectorSaveResponse.text();
+ console.log(errorData);
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${errorData}`,
+ };
+ }
+
+ // Insert into database
+ const insertResponse = await db
+ .insert(storedContent)
+ .values({
+ content: pageContent,
+ title: metadata.title,
+ description: metadata.description,
+ url: input.content,
+ baseUrl: metadata.baseUrl,
+ image: metadata.image,
+ savedAt: new Date(),
+ userId: data.user.id,
+ type,
+ })
+ .returning({ id: storedContent.id });
+
+ const contentId = insertResponse[0]?.id;
+ if (!contentId) {
+ return {
+ success: false,
+ data: 0,
+ error: "Something went wrong while saving the document to the database",
+ };
+ }
+
+ if (storeToSpaces.length > 0) {
+ // Adding the many-to-many relationship between content and spaces
+ const spaceData = await db
+ .select()
+ .from(space)
+ .where(
+ and(
+ inArray(
+ space.id,
+ storeToSpaces.map((s) => parseInt(s)),
+ ),
+ eq(space.user, data.user.id),
+ ),
+ )
+ .all();
+
+ await Promise.all(
+ spaceData.map(async (space) => {
+ await db
+ .insert(contentToSpace)
+ .values({ contentId: contentId, spaceId: space.id });
+ }),
+ );
+ }
+
+ try {
+ const response = await vectorSaveResponse.json();
+
+ const expectedResponse = z.object({ status: z.literal("ok") });
+
+ const parsedResponse = expectedResponse.safeParse(response);
+
+ if (!parsedResponse.success) {
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${parsedResponse.error.message}`,
+ };
+ }
+
+ return {
+ success: true,
+ data: 1,
+ };
+ } catch (e) {
+ return {
+ success: false,
+ data: 0,
+ error: `Failed to save to vector store. Backend returned error: ${e}`,
+ };
+ }
+};
diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts
new file mode 100644
index 00000000..dc71252e
--- /dev/null
+++ b/apps/web/app/actions/fetchers.ts
@@ -0,0 +1,142 @@
+"use server";
+
+import { eq, inArray, not, sql } from "drizzle-orm";
+import { db } from "../../server/db";
+import {
+ Content,
+ contentToSpace,
+ storedContent,
+ users,
+} from "../../server/db/schema";
+import { ServerActionReturnType, Space } from "./types";
+import { auth } from "../../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 };
+};
+
+export const getAllMemories = async (
+ freeMemoriesOnly: boolean = false,
+): ServerActionReturnType<Content[]> => {
+ const data = await auth();
+
+ if (!data || !data.user) {
+ return { error: "Not authenticated", success: false };
+ }
+
+ if (!freeMemoriesOnly) {
+ // Returns all memories, no matter the space.
+ const memories = await db.query.storedContent.findMany({
+ where: eq(users, data.user.id),
+ });
+
+ return { success: true, data: memories };
+ }
+
+ // This only returns memories that are not a part of any space.
+ // This is useful for home page where we want to show a list of spaces and memories.
+ const contentNotInAnySpace = await db
+ .select()
+ .from(storedContent)
+ .where(
+ not(
+ eq(
+ storedContent.id,
+ db
+ .select({ contentId: contentToSpace.contentId })
+ .from(contentToSpace),
+ ),
+ ),
+ )
+ .execute();
+
+ return { success: true, data: contentNotInAnySpace };
+};
+
+export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{
+ spaces: Space[];
+ memories: Content[];
+}> => {
+ 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 };
+ });
+
+ // const contentCountBySpace = await db
+ // .select({
+ // spaceId: contentToSpace.spaceId,
+ // count: sql<number>`count(*)`.mapWith(Number),
+ // })
+ // .from(contentToSpace)
+ // .where(
+ // inArray(
+ // contentToSpace.spaceId,
+ // spacesWithoutUser.map((space) => space.id),
+ // ),
+ // )
+ // .groupBy(contentToSpace.spaceId)
+ // .execute();
+
+ // console.log(contentCountBySpace);
+
+ // get a count with space mappings like spaceID: count (number of memories in that space)
+ const contentCountBySpace = await db
+ .select({
+ spaceId: contentToSpace.spaceId,
+ count: sql<number>`count(*)`.mapWith(Number),
+ })
+ .from(contentToSpace)
+ .where(
+ inArray(
+ contentToSpace.spaceId,
+ spacesWithoutUser.map((space) => space.id),
+ ),
+ )
+ .groupBy(contentToSpace.spaceId)
+ .execute();
+
+ console.log(contentCountBySpace);
+
+ const contentNotInAnySpace = await db
+ .select()
+ .from(storedContent)
+ .where(
+ not(
+ eq(
+ storedContent.id,
+ db
+ .select({ contentId: contentToSpace.contentId })
+ .from(contentToSpace),
+ ),
+ ),
+ )
+ .execute();
+
+ return {
+ success: true,
+ data: { spaces: spacesWithoutUser, memories: contentNotInAnySpace },
+ };
+};
diff --git a/apps/web/app/actions/types.ts b/apps/web/app/actions/types.ts
new file mode 100644
index 00000000..5c5afc5c
--- /dev/null
+++ b/apps/web/app/actions/types.ts
@@ -0,0 +1,11 @@
+export type Space = {
+ id: number;
+ name: string;
+ numberOfMemories?: number;
+};
+
+export type ServerActionReturnType<T> = Promise<{
+ error?: string;
+ success: boolean;
+ data?: T;
+}>;