diff --git a/SETUP-GUIDE.md b/SETUP-GUIDE.md index 7d69b545..f51b1cb1 100644 --- a/SETUP-GUIDE.md +++ b/SETUP-GUIDE.md @@ -13,12 +13,13 @@ 3. Create a `.dev.vars` file in `apps/web` with the following content: ```bash -GOOGLE_CLIENT_ID="-" -GOOGLE_CLIENT_SECRET="-" +GOOGLE_CLIENT_ID="-" // required, visit https://developers.google.com/identity/protocols/oauth2 +GOOGLE_CLIENT_SECRET="-" // required NEXTAUTH_SECRET='nextauthsecret' DATABASE_URL='database.sqlite' NEXTAUTH_URL='http://localhost:3000' BACKEND_SECURITY_KEY='veryrandomsecuritykey' +BACKEND_BASE_URL="where your backend is hosted" ``` 4. Setup the database: @@ -28,10 +29,10 @@ First, edit the `wrangler.toml` file in `apps/web` to point the d1 database to y You can create a d1 database by running this command ``` -wrangler d1 create DATABASE_NAME +bunx wrangler d1 create ``` -And then replace these values +And then replace database_name and database_id with the values ``` [[d1_databases]] @@ -43,10 +44,12 @@ database_id = "YOUR_DB_ID" Simply run this command in `apps/web` ``` -wrangler d1 execute dev-d1-anycontext --local --file=db/prepare.sql +bunx wrangler d1 migrations apply ``` -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 removing the `--local` 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. diff --git a/apps/browser-rendering b/apps/browser-rendering index b37c9623..4d21045a 160000 --- a/apps/browser-rendering +++ b/apps/browser-rendering @@ -1 +1 @@ -Subproject commit b37c962365a36cf342a31a196f4908f4f1343553 +Subproject commit 4d21045a45fdbf56b7483d3704ae0474ebf044fb diff --git a/apps/cf-ai-backend/README.md b/apps/cf-ai-backend/README.md index 86409f29..91d6b77e 100644 --- a/apps/cf-ai-backend/README.md +++ b/apps/cf-ai-backend/README.md @@ -1,58 +1,50 @@ -# Hono minimal project +baseURL: https://new-cf-ai-backend.dhravya.workers.dev -This is a minimal project with [Hono](https://github.com/honojs/hono/) for Cloudflare Workers. +Authentication: +You must authenticate with a header and `Authorization: bearer token` for each request in `/api/*` routes. -## Features +### Add content: -- Minimal -- TypeScript -- Wrangler to develop and deploy. -- [Jest](https://jestjs.io/ja/) for testing. - -## Usage - -Initialize +POST `/api/add` with ``` -npx create-cloudflare my-app https://github.com/honojs/hono-minimal +body { + pageContent: z.string(), + title: z.string().optional(), + description: z.string().optional(), + space: z.string().optional(), + url: z.string(), + user: z.string(), +} ``` -Install +### Query without user data + +GET `/api/ask` with +query `?query=testing` + +(this is temp but works perfectly, will change soon for chat use cases specifically) + +### Query vectorize and get results in natural language + +POST `/api/chat` with ``` -yarn install +query paramters (?query=...&" { + query: z.string(), + topK: z.number().optional().default(10), + user: z.string(), + spaces: z.string().optional(), + sourcesOnly: z.string().optional().default("false"), + model: z.string().optional().default("gpt-4o"), + } + +body z.object({ + chatHistory: z.array(contentObj).optional(), +}); ``` -Develop +### Delete vectors -``` -yarn dev -``` - -Test - -``` -yarn test -``` - -Deploy - -``` -yarn deploy -``` - -## Examples - -See: - -## For more information - -See: - -## Author - -Yusuke Wada - -## License - -MIT +DELETE `/api/delete` with +query param websiteUrl, user diff --git a/apps/cf-ai-backend/src/helper.ts b/apps/cf-ai-backend/src/helper.ts index 87495c59..cef781be 100644 --- a/apps/cf-ai-backend/src/helper.ts +++ b/apps/cf-ai-backend/src/helper.ts @@ -21,8 +21,6 @@ export async function initQuery( index: c.env.VECTORIZE_INDEX, }); - const DEFAULT_MODEL = "gpt-4o"; - let selectedModel: | ReturnType> | ReturnType> @@ -52,12 +50,6 @@ export async function initQuery( break; } - if (!selectedModel) { - throw new Error( - `Model ${model} not found and default model ${DEFAULT_MODEL} is also not available.`, - ); - } - return { store, model: selectedModel }; } @@ -72,19 +64,46 @@ export async function deleteDocument({ c: Context<{ Bindings: Env }>; store: CloudflareVectorizeStore; }) { - const toBeDeleted = `${url}-${user}`; + const toBeDeleted = `${url}#supermemory-web`; const random = seededRandom(toBeDeleted); const uuid = random().toString(36).substring(2, 15) + random().toString(36).substring(2, 15); - await c.env.KV.list({ prefix: uuid }).then(async (keys) => { - for (const key of keys.keys) { - await c.env.KV.delete(key.name); - await store.delete({ ids: [key.name] }); + const allIds = await c.env.KV.list({ prefix: uuid }); + + if (allIds.keys.length > 0) { + const savedVectorIds = allIds.keys.map((key) => key.name); + const vectors = await c.env.VECTORIZE_INDEX.getByIds(savedVectorIds); + // We don't actually delete document directly, we just remove the user from the metadata. + // If there's no user left, we can delete the document. + const newVectors = vectors.map((vector) => { + delete vector.metadata[`user-${user}`]; + + // Get count of how many users are left + const userCount = Object.keys(vector.metadata).filter((key) => + key.startsWith("user-"), + ).length; + + // If there's no user left, we can delete the document. + // need to make sure that every chunk is deleted otherwise it would be problematic. + if (userCount === 0) { + store.delete({ ids: savedVectorIds }); + void Promise.all(savedVectorIds.map((id) => c.env.KV.delete(id))); + return null; + } + + return vector; + }); + + // If all vectors are null (deleted), we can delete the KV too. Otherwise, we update (upsert) the vectors. + if (newVectors.every((v) => v === null)) { + await c.env.KV.delete(uuid); + } else { + await c.env.VECTORIZE_INDEX.upsert(newVectors.filter((v) => v !== null)); } - }); + } } export async function batchCreateChunksAndEmbeddings({ @@ -98,19 +117,47 @@ export async function batchCreateChunksAndEmbeddings({ chunks: string[]; context: Context<{ Bindings: Env }>; }) { - const ourID = `${body.url}-${body.user}`; - - await deleteDocument({ url: body.url, user: body.user, c: context, store }); - + //! NOTE that we use #supermemory-web to ensure that + //! If a user saves it through the extension, we don't want other users to be able to see it. + // Requests from the extension should ALWAYS have a unique ID with the USERiD in it. + // I cannot stress this enough, important for security. + const ourID = `${body.url}#supermemory-web`; const random = seededRandom(ourID); + const uuid = + random().toString(36).substring(2, 15) + + random().toString(36).substring(2, 15); + + const allIds = await context.env.KV.list({ prefix: uuid }); + + // If some chunks for that content already exist, we'll just update the metadata to include + // the user. + if (allIds.keys.length > 0) { + const savedVectorIds = allIds.keys.map((key) => key.name); + const vectors = await context.env.VECTORIZE_INDEX.getByIds(savedVectorIds); + + // Now, we'll update all vector metadatas with one more userId and all spaceIds + const newVectors = vectors.map((vector) => { + vector.metadata = { + ...vector.metadata, + [`user-${body.user}`]: 1, + + // For each space in body, add the spaceId to the vector metadata + ...(body.spaces ?? [])?.reduce((acc, space) => { + acc[`space-${body.user}-${space}`] = 1; + return acc; + }, {}), + }; + + return vector; + }); + + await context.env.VECTORIZE_INDEX.upsert(newVectors); + return; + } for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; - const uuid = - random().toString(36).substring(2, 15) + - random().toString(36).substring(2, 15) + - "-" + - i; + const chunkId = `${uuid}-${i}`; const newPageContent = `Title: ${body.title}\nDescription: ${body.description}\nURL: ${body.url}\nContent: ${chunk}`; @@ -121,19 +168,25 @@ export async function batchCreateChunksAndEmbeddings({ metadata: { title: body.title?.slice(0, 50) ?? "", description: body.description ?? "", - space: body.space ?? "", url: body.url, - user: body.user, + type: body.type ?? "page", + content: newPageContent, + + [`user-${body.user}`]: 1, + ...body.spaces?.reduce((acc, space) => { + acc[`space-${body.user}-${space}`] = 1; + return acc; + }, {}), }, }, ], { - ids: [uuid], + ids: [chunkId], }, ); console.log("Docs added: ", docs); - await context.env.KV.put(uuid, ourID); + await context.env.KV.put(chunkId, ourID); } } diff --git a/apps/cf-ai-backend/src/index.test.ts b/apps/cf-ai-backend/src/index.test.ts deleted file mode 100644 index bbf66fb5..00000000 --- a/apps/cf-ai-backend/src/index.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import app from "."; - -// TODO: write more tests -describe("Test the application", () => { - it("Should return 200 response", async () => { - const res = await app.request("http://localhost/"); - expect(res.status).toBe(200); - }), - it("Should return 404 response", async () => { - const res = await app.request("http://localhost/404"); - expect(res.status).toBe(404); - }); -}); diff --git a/apps/cf-ai-backend/src/index.ts b/apps/cf-ai-backend/src/index.ts index 19770dec..effdf517 100644 --- a/apps/cf-ai-backend/src/index.ts +++ b/apps/cf-ai-backend/src/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { Hono } from "hono"; -import { CoreMessage, streamText } from "ai"; +import { CoreMessage, generateText, streamText } from "ai"; import { chatObj, Env, vectorObj } from "./types"; import { batchCreateChunksAndEmbeddings, @@ -14,9 +14,17 @@ import { bearerAuth } from "hono/bearer-auth"; import { zValidator } from "@hono/zod-validator"; import chunkText from "./utils/chonker"; import { systemPrompt, template } from "./prompts/prompt1"; +import { swaggerUI } from "@hono/swagger-ui"; const app = new Hono<{ Bindings: Env }>(); +app.get( + "/ui", + swaggerUI({ + url: "/doc", + }), +); + // ------- MIDDLEWARES ------- app.use("*", poweredBy()); app.use("*", timing()); @@ -31,6 +39,17 @@ app.use("/api/", async (c, next) => { }); // ------- MIDDLEWARES END ------- +const fileSchema = z + .instanceof(File) + .refine( + (file) => file.size <= 10 * 1024 * 1024, + "File size should be less than 10MB", + ) // Validate file size + .refine( + (file) => ["image/jpeg", "image/png", "image/gif"].includes(file.type), + "Invalid file type", + ); // Validate file type + app.get("/", (c) => { return c.text("Supermemory backend API is running!"); }); @@ -54,6 +73,82 @@ app.post("/api/add", zValidator("json", vectorObj), async (c) => { return c.json({ status: "ok" }); }); +app.post( + "/api/add-with-image", + zValidator( + "form", + z.object({ + images: z + .array(fileSchema) + .min(1, "At least one image is required") + .optional(), + "images[]": z + .array(fileSchema) + .min(1, "At least one image is required") + .optional(), + text: z.string().optional(), + spaces: z.array(z.string()).optional(), + url: z.string(), + user: z.string(), + }), + (c) => { + console.log(c); + }, + ), + async (c) => { + const body = c.req.valid("form"); + + const { store } = await initQuery(c); + + if (!(body.images || body["images[]"])) { + return c.json({ status: "error", message: "No images found" }, 400); + } + + const imagePromises = (body.images ?? body["images[]"]).map( + async (image) => { + const buffer = await image.arrayBuffer(); + const input = { + image: [...new Uint8Array(buffer)], + prompt: + "What's in this image? caption everything you see in great detail. If it has text, do an OCR and extract all of it.", + max_tokens: 1024, + }; + const response = await c.env.AI.run( + "@cf/llava-hf/llava-1.5-7b-hf", + input, + ); + console.log(response.description); + return response.description; + }, + ); + + const imageDescriptions = await Promise.all(imagePromises); + + await batchCreateChunksAndEmbeddings({ + store, + body: { + url: body.url, + user: body.user, + type: "image", + description: + imageDescriptions.length > 1 + ? `A group of ${imageDescriptions.length} images on ${body.url}` + : imageDescriptions[0], + spaces: body.spaces, + pageContent: imageDescriptions.join("\n"), + title: "Image content from the web", + }, + chunks: [ + imageDescriptions, + ...(body.text ? chunkText(body.text, 1536) : []), + ].flat(), + context: c, + }); + + return c.json({ status: "ok" }); + }, +); + app.get( "/api/ask", zValidator( @@ -85,8 +180,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"), @@ -97,30 +192,29 @@ 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(",") ?? [undefined]; // Get the AI model maker and vector store const { model, store } = await initQuery(c, query.model); - const filter: VectorizeVectorMetadataFilter = { user: query.user }; + const filter: VectorizeVectorMetadataFilter = { + [`user-${query.user}`]: 1, + }; + 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 && spaces.length > 1) { // it's possible for space list to be [undefined] so we only add space filter conditionally - filter.space = space; + filter[`space-${query.user}-${space}`] = 1; } // Because there's no OR operator in the filter, we have to make multiple queries @@ -173,29 +267,20 @@ app.post( dataPoint.id.toString(), ); - // We are getting the content ID back, so that the frontend can show the actual sources properly. - // it IS a lot of DB calls, i completely agree. - // TODO: return metadata value here, so that the frontend doesn't have to re-fetch anything. const storedContent = await Promise.all( idsAsStrings.map(async (id) => await c.env.KV.get(id)), ); - return c.json({ ids: storedContent }); + const metadata = normalizedData.map((datapoint) => datapoint.metadata); + + return c.json({ ids: storedContent, metadata }); } - const vec = responses.matches.map((data) => ({ metadata: data.metadata })); - - const vecWithScores = vec.map((v, i) => ({ - ...v, - score: sortedHighScoreData[i].score, - normalisedScore: sortedHighScoreData[i].normalizedScore, - })); - - const preparedContext = vecWithScores.map( - ({ metadata, score, normalisedScore }) => ({ + const preparedContext = normalizedData.map( + ({ metadata, score, normalizedScore }) => ({ context: `Website title: ${metadata!.title}\nDescription: ${metadata!.description}\nURL: ${metadata!.url}\nContent: ${metadata!.text}`, score, - normalisedScore, + normalizedScore, }), ); @@ -245,4 +330,20 @@ app.delete( }, ); +app.get('/api/editorai', zValidator( + "query", + z.object({ + context: z.string(), + request: z.string(), + }), +), async (c)=> { + const { context, request } = c.req.valid("query"); + + const { model } = await initQuery(c); + + const {text} = await generateText({ model, prompt: `${request}-${context}`, maxTokens: 224 }); + + return c.json({completion: text}); +}) + export default app; diff --git a/apps/cf-ai-backend/src/prompts/prompt1.ts b/apps/cf-ai-backend/src/prompts/prompt1.ts index aa7694d3..289495b6 100644 --- a/apps/cf-ai-backend/src/prompts/prompt1.ts +++ b/apps/cf-ai-backend/src/prompts/prompt1.ts @@ -6,28 +6,24 @@ 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 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 tags and your final answer between tags, formatting both in markdown. - +- provide your justification in the end, in a 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 }) => { // Map over contexts to generate the context and score parts const contextParts = contexts .map( - ({ context, score, normalisedScore }) => ` + ({ context, normalisedScore }) => ` ${context} - score: ${score} normalisedScore: ${normalisedScore} `, ) diff --git a/apps/cf-ai-backend/src/types.ts b/apps/cf-ai-backend/src/types.ts index bea4bf80..417d6320 100644 --- a/apps/cf-ai-backend/src/types.ts +++ b/apps/cf-ai-backend/src/types.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export type Env = { VECTORIZE_INDEX: VectorizeIndex; - AI: Fetcher; + AI: Ai; SECURITY_KEY: string; OPENAI_API_KEY: string; GOOGLE_AI_API_KEY: string; @@ -43,7 +43,8 @@ export const vectorObj = z.object({ pageContent: z.string(), title: z.string().optional(), description: z.string().optional(), - space: z.string().optional(), + spaces: z.array(z.string()).optional(), url: z.string(), user: z.string(), + type: z.string().optional().default("page"), }); 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(); - return data.data[0].embedding; + 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 json.data.data[0].embedding; } } diff --git a/apps/cf-ai-backend/src/utils/chonker.ts b/apps/cf-ai-backend/src/utils/chonker.ts index 39d4b458..c63020be 100644 --- a/apps/cf-ai-backend/src/utils/chonker.ts +++ b/apps/cf-ai-backend/src/utils/chonker.ts @@ -1,5 +1,8 @@ import nlp from "compromise"; +/** + * Split text into chunks of specified max size with some overlap for continuity. + */ export default function chunkText( text: string, maxChunkSize: number, diff --git a/apps/cf-ai-backend/src/utils/seededRandom.ts b/apps/cf-ai-backend/src/utils/seededRandom.ts index 36a1e4f9..9e315ee8 100644 --- a/apps/cf-ai-backend/src/utils/seededRandom.ts +++ b/apps/cf-ai-backend/src/utils/seededRandom.ts @@ -1,5 +1,9 @@ import { MersenneTwister19937, integer } from "random-js"; +/** + * Hashes a string to a 32-bit integer. + * @param {string} seed - The input string to hash. + */ function hashString(seed: string) { let hash = 0; for (let i = 0; i < seed.length; i++) { @@ -10,6 +14,9 @@ function hashString(seed: string) { return hash; } +/** + * returns a funtion that generates same sequence of random numbers for a given seed between 0 and 1. + */ export function seededRandom(seed: string) { const seedHash = hashString(seed); const engine = MersenneTwister19937.seed(seedHash); diff --git a/apps/cf-ai-backend/tsconfig.json b/apps/cf-ai-backend/tsconfig.json index 2b75d5a0..fcdf6914 100644 --- a/apps/cf-ai-backend/tsconfig.json +++ b/apps/cf-ai-backend/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "lib": ["ES2020"], - "types": ["@cloudflare/workers-types"] + "types": ["@cloudflare/workers-types"], + "downlevelIteration": true } } diff --git a/apps/cf-ai-backend/wrangler.toml b/apps/cf-ai-backend/wrangler.toml index db0ae945..fa883195 100644 --- a/apps/cf-ai-backend/wrangler.toml +++ b/apps/cf-ai-backend/wrangler.toml @@ -5,7 +5,7 @@ node_compat = true [[vectorize]] binding = "VECTORIZE_INDEX" -index_name = "supermem-vector" +index_name = "supermem-vector-dev" [ai] binding = "AI" diff --git a/apps/web/app/(auth)/auth-buttons.tsx b/apps/web/app/(auth)/auth-buttons.tsx index 0e99213e..5b0ad06e 100644 --- a/apps/web/app/(auth)/auth-buttons.tsx +++ b/apps/web/app/(auth)/auth-buttons.tsx @@ -2,7 +2,7 @@ import { Button } from "@repo/ui/shadcn/button"; import React from "react"; -import { signIn } from "../helpers/server/auth"; +import { signIn } from "../../server/auth"; function SignIn() { return ( diff --git a/apps/web/app/(auth)/signin/page.tsx b/apps/web/app/(auth)/signin/page.tsx index 2f913a75..d7bad8da 100644 --- a/apps/web/app/(auth)/signin/page.tsx +++ b/apps/web/app/(auth)/signin/page.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import Link from "next/link"; import Logo from "@/public/logo.svg"; -import { signIn } from "@/app/helpers/server/auth"; +import { signIn } from "@/server/auth"; import { Google } from "@repo/ui/components/icons"; export const runtime = "edge"; @@ -9,26 +9,22 @@ export const runtime = "edge"; async function Signin() { return (
-
+
- SuperMemory logo + SuperMemory logo SuperMemory.ai

- Hello, human{" "} + Hello, human

Write, ideate, and learn with all the wisdom of your bookmarks.

{ @@ -40,7 +36,7 @@ async function Signin() { >
-
- By continuing, you agree to the - - {" "} +
+ Terms of Service {" "} - and - + | + {" "} Privacy Policy
-
+
Ready for your{" "} Second brain? diff --git a/apps/web/app/(canvas)/canvas.tsx b/apps/web/app/(canvas)/canvas.tsx new file mode 100644 index 00000000..9ec57d6d --- /dev/null +++ b/apps/web/app/(canvas)/canvas.tsx @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Editor, Tldraw, setUserPreferences, TLStoreWithStatus } from "tldraw"; +import { createAssetFromUrl } from "./lib/createAssetUrl"; +import "tldraw/tldraw.css"; +import { components } from "./enabledComp"; +import { twitterCardUtil } from "./twitterCard"; +import createEmbedsFromUrl from "./lib/createEmbeds"; +import { loadRemoteSnapshot } from "./lib/loadSnap"; +import { SaveStatus } from "./savesnap"; +import { getAssetUrls } from '@tldraw/assets/selfHosted' +import { memo } from 'react'; + +export const Canvas = memo(()=>{ + const [storeWithStatus, setStoreWithStatus] = useState({ + status: "loading", + }); + useEffect(() => { + const fetchStore = async () => { + const store = await loadRemoteSnapshot(); + + setStoreWithStatus({ + store: store, + status: "not-synced", + }); + }; + + fetchStore(); + }, []); + + const handleMount = useCallback((editor: Editor) => { + (window as any).app = editor; + (window as any).editor = editor; + editor.registerExternalAssetHandler("url", createAssetFromUrl); + editor.registerExternalContentHandler("url", ({ url, point, sources }) => { + createEmbedsFromUrl({ url, point, sources, editor }); + }); + }, []); + + setUserPreferences({ id: "supermemory", isDarkMode: true }); + + const assetUrls = getAssetUrls() + return ( + +
+ +
+
+ ); +}) diff --git a/apps/web/app/(canvas)/canvas/layout.tsx b/apps/web/app/(canvas)/canvas/layout.tsx new file mode 100644 index 00000000..9bc3b6d7 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/layout.tsx @@ -0,0 +1,13 @@ +import "../canvasStyles.css"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx new file mode 100644 index 00000000..366a4481 --- /dev/null +++ b/apps/web/app/(canvas)/canvas/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +// import Canvas from "./_components/canvas"; +import {Canvas} from "../canvas"; +import React, { useState } from "react"; +// import ReactTextareaAutosize from "react-textarea-autosize"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { + DragSvg, + SettingsSvg, + LinkSvg, + ThreeDBlock, + TextLoadingSvg, +} from "../svg"; + +function page() { + const [value, setValue] = useState(""); + const [fullScreen, setFullScreen] = useState(false); + + return ( +
+
+ + {setTimeout(()=> setFullScreen(false), 50)}} onCollapse={()=> {setTimeout(()=> setFullScreen(true), 50)}} defaultSize={30} collapsible={true} minSize={22}> +
+
+ Change Filters + +
+
+ { + setValue(e.target.value); + }} + value={value} + // rows={1} + className="w-full resize-none rounded-xl bg-[#151515] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52" + /> +
+
+
+ +

+ Nvidia will most likely create monopoly in software industry + as they are already largest player in GPU hardware by 20... +

+
+
+ +
+
+

+ Nvidia currently dominates the GPU hardware market, with + a market share over 97%. This has led some to argue... +

+
+

+ From space: GPU GOATS +

+
+
+
+ +
+
+

+ Nvidia currently dominates the GPU hardware market, with + a market share over 97%. This has led some to argue... +

+
+

+ Page url: + https://www.cnbc.com/2024/05/23/nvidia-keeps-hitting-records-can-investors-still-buy-the-stock.html?&qsearchterm=nvidia +

+
+
+
+
+
+ + {/*
*/} +
+ +
+ {/*
*/} +
+ +
+ +
+
+
+
+
+ ); +} + +export default page; diff --git a/apps/web/app/(canvas)/canvasStyles.css b/apps/web/app/(canvas)/canvasStyles.css new file mode 100644 index 00000000..a53d8c96 --- /dev/null +++ b/apps/web/app/(canvas)/canvasStyles.css @@ -0,0 +1,24 @@ +.tl-background { + background: #1F2428 !important; +} + +.tlui-style-panel.tlui-style-panel__wrapper, .tlui-navigation-panel::before ,.tlui-menu-zone, .tlui-toolbar__tools, .tlui-popover__content, .tlui-menu, .tlui-button__help, .tlui-help-menu, .tlui-dialog__content { + background: #2C3439 !important; + border-top: #2C3439 !important; + border-right: #2C3439 !important; + border-bottom: #2C3439 !important; + border-left: #2C3439 !important; +} + +.tlui-navigation-panel::before { + border-top: #2C3439 !important; + border-right: #2C3439 !important; +} + +.tlui-minimap { + background: #2C3439 !important; +} + +.tlui-minimap__canvas { + background: #1F2428 !important; +} \ No newline at end of file diff --git a/apps/web/app/(canvas)/enabledComp.tsx b/apps/web/app/(canvas)/enabledComp.tsx new file mode 100644 index 00000000..5dbe6ee7 --- /dev/null +++ b/apps/web/app/(canvas)/enabledComp.tsx @@ -0,0 +1,22 @@ +import { TLUiComponents } from "tldraw"; + +export const components: Partial = { + ActionsMenu: null, + MainMenu: null, + QuickActions: null, + TopPanel: null, + DebugPanel: null, + DebugMenu: null, + // Minimap: null, + // ContextMenu: null, + // HelpMenu: null, + // ZoomMenu: null, + // StylePanel: null, + // PageMenu: null, + // NavigationPanel: null, + // Toolbar: null, + // KeyboardShortcutsDialog: null, + // HelperButtons: null, + // SharePanel: null, + // MenuPanel: null, +}; \ No newline at end of file diff --git a/apps/web/app/(canvas)/lib/createAssetUrl.ts b/apps/web/app/(canvas)/lib/createAssetUrl.ts new file mode 100644 index 00000000..05c2baea --- /dev/null +++ b/apps/web/app/(canvas)/lib/createAssetUrl.ts @@ -0,0 +1,94 @@ +import { + AssetRecordType, + TLAsset, + getHashForString, + truncateStringWithEllipsis, +} from "tldraw"; +// import { BOOKMARK_ENDPOINT } from './config' + +interface ResponseBody { + title?: string; + description?: string; + image?: string; +} + +export async function createAssetFromUrl({ + url, +}: { + type: "url"; + url: string; +}): Promise { + // try { + // // First, try to get the meta data from our endpoint + // const meta = (await ( + // await fetch(BOOKMARK_ENDPOINT, { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ + // url, + // }), + // }) + // ).json()) as ResponseBody + + // return { + // id: AssetRecordType.createId(getHashForString(url)), + // typeName: 'asset', + // type: 'bookmark', + // props: { + // src: url, + // description: meta.description ?? '', + // image: meta.image ?? '', + // title: meta.title ?? truncateStringWithEllipsis(url, 32), + // }, + // meta: {}, + // } + // } catch (error) { + // Otherwise, fallback to fetching data from the url + + let meta: { image: string; title: string; description: string }; + + try { + const resp = await fetch(url, { method: "GET", mode: "no-cors" }); + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, "text/html"); + meta = { + image: + doc.head + .querySelector('meta[property="og:image"]') + ?.getAttribute("content") ?? "", + title: + doc.head + .querySelector('meta[property="og:title"]') + ?.getAttribute("content") ?? truncateStringWithEllipsis(url, 32), + description: + doc.head + .querySelector('meta[property="og:description"]') + ?.getAttribute("content") ?? "", + }; + } catch (error) { + console.error(error); + meta = { + image: "", + title: truncateStringWithEllipsis(url, 32), + description: "", + }; + } + + // Create the bookmark asset from the meta + return { + id: AssetRecordType.createId(getHashForString(url)), + typeName: "asset", + type: "bookmark", + props: { + src: url, + image: meta.image, + title: meta.title, + description: meta.description, + favicon: meta.image, + }, + meta: {}, + }; + // } +} diff --git a/apps/web/app/(canvas)/lib/createEmbeds.ts b/apps/web/app/(canvas)/lib/createEmbeds.ts new file mode 100644 index 00000000..53d81533 --- /dev/null +++ b/apps/web/app/(canvas)/lib/createEmbeds.ts @@ -0,0 +1,142 @@ +import { AssetRecordType, Editor, TLAsset, TLAssetId, TLBookmarkShape, TLExternalContentSource, TLShapePartial, Vec, VecLike, createShapeId, getEmbedInfo, getHashForString } from "tldraw"; + +export default async function createEmbedsFromUrl({url, point, sources, editor}: { + url: string + point: VecLike | undefined + sources: TLExternalContentSource[] | undefined + editor: Editor +}){ + + const position = + point ?? + (editor.inputs.shiftKey + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center); + + if (url?.includes("x.com") || url?.includes("twitter.com")) { + return editor.createShape({ + type: "Twittercard", + x: position.x - 250, + y: position.y - 150, + props: { url: url }, + }); + + } + + // try to paste as an embed first + const embedInfo = getEmbedInfo(url); + + if (embedInfo) { + return editor.putExternalContent({ + type: "embed", + url: embedInfo.url, + point, + embed: embedInfo.definition, + }); + } + + const assetId: TLAssetId = AssetRecordType.createId( + getHashForString(url), + ); + const shape = createEmptyBookmarkShape(editor, url, position); + + // Use an existing asset if we have one, or else else create a new one + let asset = editor.getAsset(assetId) as TLAsset; + let shouldAlsoCreateAsset = false; + if (!asset) { + shouldAlsoCreateAsset = true; + try { + const bookmarkAsset = await editor.getAssetForExternalContent({ + type: "url", + url, + }); + const fetchWebsite: { + title?: string; + image?: string; + description?: string; + } = await (await fetch(`/api/unfirlsite?website=${url}`, { + method: "POST" + })).json() + if (bookmarkAsset){ + if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title; + if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image; + if (fetchWebsite.description) bookmarkAsset.props.description = fetchWebsite.description; + } + if (!bookmarkAsset) throw Error("Could not create an asset"); + asset = bookmarkAsset; + } catch (e) { + console.log(e) + return; + } + } + + editor.batch(() => { + if (shouldAlsoCreateAsset) { + editor.createAssets([asset]); + } + + editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { + assetId: asset.id, + }, + }, + ]); + }); +} + +function centerSelectionAroundPoint(editor: Editor, position: VecLike) { + // Re-position shapes so that the center of the group is at the provided point + const viewportPageBounds = editor.getViewportPageBounds() + let selectionPageBounds = editor.getSelectionPageBounds() + + if (selectionPageBounds) { + const offset = selectionPageBounds!.center.sub(position) + + editor.updateShapes( + editor.getSelectedShapes().map((shape) => { + const localRotation = editor.getShapeParentTransform(shape).decompose().rotation + const localDelta = Vec.Rot(offset, -localRotation) + return { + id: shape.id, + type: shape.type, + x: shape.x! - localDelta.x, + y: shape.y! - localDelta.y, + } + }) + ) + } + + // Zoom out to fit the shapes, if necessary + selectionPageBounds = editor.getSelectionPageBounds() + if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { + editor.zoomToSelection() + } +} + +export function createEmptyBookmarkShape( + editor: Editor, + url: string, + position: VecLike +): TLBookmarkShape { + const partial: TLShapePartial = { + id: createShapeId(), + type: 'bookmark', + x: position.x - 150, + y: position.y - 160, + opacity: 1, + props: { + assetId: null, + url, + }, + } + + editor.batch(() => { + editor.createShapes([partial]).select(partial.id) + centerSelectionAroundPoint(editor, position) + }) + + return editor.getShape(partial.id) as TLBookmarkShape +} \ No newline at end of file diff --git a/apps/web/app/(canvas)/lib/loadSnap.ts b/apps/web/app/(canvas)/lib/loadSnap.ts new file mode 100644 index 00000000..15aad998 --- /dev/null +++ b/apps/web/app/(canvas)/lib/loadSnap.ts @@ -0,0 +1,13 @@ +import { createTLStore, defaultShapeUtils } from "tldraw"; +import { twitterCardUtil } from "../twitterCard"; +export async function loadRemoteSnapshot() { + const res = await fetch( + "https://learning-cf.pruthvirajthinks.workers.dev/get/page3", + ); + const snapshot = JSON.parse(await res.json()); + const newStore = createTLStore({ + shapeUtils: [...defaultShapeUtils, twitterCardUtil], + }); + newStore.loadSnapshot(snapshot); + return newStore; +} \ No newline at end of file diff --git a/apps/web/app/(canvas)/savesnap.tsx b/apps/web/app/(canvas)/savesnap.tsx new file mode 100644 index 00000000..f82e97e3 --- /dev/null +++ b/apps/web/app/(canvas)/savesnap.tsx @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { debounce, useEditor } from "tldraw"; + +export function SaveStatus() { + const [save, setSave] = useState("saved!"); + const editor = useEditor(); + + const debouncedSave = useCallback( + debounce(async () => { + const snapshot = editor.store.getSnapshot(); + localStorage.setItem("saved", JSON.stringify(snapshot)); + + const res = await fetch( + "https://learning-cf.pruthvirajthinks.workers.dev/post/page3", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + data: snapshot, + }), + }, + ); + + console.log(await res.json()); + setSave("saved!"); + }, 3000), + [editor], // Dependency array ensures the function is not recreated on every render + ); + + useEffect(() => { + const unsubscribe = editor.store.listen( + () => { + setSave("saving..."); + debouncedSave(); + }, + { scope: "document", source: "user" }, + ); + + return () => unsubscribe(); // Cleanup on unmount + }, [editor, debouncedSave]); + + return ; +} \ No newline at end of file diff --git a/apps/web/app/(canvas)/svg.tsx b/apps/web/app/(canvas)/svg.tsx new file mode 100644 index 00000000..bae4e614 --- /dev/null +++ b/apps/web/app/(canvas)/svg.tsx @@ -0,0 +1,97 @@ +export function SettingsSvg() { + return ( + + + + ); +} + +export function DragSvg() { + return ( + + + + ); +} + +export function TextLoadingSvg() { + return ( + + + + ); +} + +export function ThreeDBlock() { + return ( + + + + ); +} + +export function LinkSvg() { + return ( + + + + ); +} diff --git a/apps/web/app/(canvas)/twitterCard.tsx b/apps/web/app/(canvas)/twitterCard.tsx new file mode 100644 index 00000000..c5582a98 --- /dev/null +++ b/apps/web/app/(canvas)/twitterCard.tsx @@ -0,0 +1,84 @@ +import { BaseBoxShapeUtil, HTMLContainer, TLBaseShape, toDomPrecision } from "tldraw"; + +type ITwitterCardShape = TLBaseShape< + "Twittercard", + { w: number; h: number; url: string } +>; + +export class twitterCardUtil extends BaseBoxShapeUtil { + static override type = "Twittercard" as const; + + getDefaultProps(): ITwitterCardShape["props"] { + return { + w: 500, + h: 550, + url: "", + }; + } + + component(s: ITwitterCardShape) { + return ( + + + + ); + } + + indicator(shape: ITwitterCardShape) { + return ; + } +} + +function TwitterPost({ + isInteractive, + width, + height, + url, +}: { + isInteractive: boolean; + width: number; + height: number; + url: string; +}) { + const link = (() => { + try { + const urlObj = new URL(url); + const path = urlObj.pathname; + return path; + } catch (error) { + console.error("Invalid URL", error); + return null; + } + })(); + + return ( +