mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-09 22:31:10 +00:00
Merge branch 'codetorso' into kartik
This commit is contained in:
commit
af90b960af
56 changed files with 1451 additions and 361 deletions
|
|
@ -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 <YOUR_DATABASE_NAME>
|
||||
```
|
||||
|
||||
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 <YOUR_DATABASE_NAME>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit b37c962365a36cf342a31a196f4908f4f1343553
|
||||
Subproject commit 4d21045a45fdbf56b7483d3704ae0474ebf044fb
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"test": "jest --verbose",
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"dev": "wrangler dev --remote --port 8686",
|
||||
"start": "wrangler dev",
|
||||
"unsafe-reset-vector-db": "wrangler vectorize delete supermem-vector && wrangler vectorize create --dimensions=1536 supermem-vector-1 --metric=cosine"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ export async function initQuery(
|
|||
index: c.env.VECTORIZE_INDEX,
|
||||
});
|
||||
|
||||
const DEFAULT_MODEL = "gpt-4o";
|
||||
|
||||
let selectedModel:
|
||||
| ReturnType<ReturnType<typeof createOpenAI>>
|
||||
| ReturnType<ReturnType<typeof createGoogleGenerativeAI>>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
@ -18,7 +18,12 @@ import { swaggerUI } from "@hono/swagger-ui";
|
|||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get("/doc", swaggerUI({ url: "/doc" }));
|
||||
app.get(
|
||||
"/ui",
|
||||
swaggerUI({
|
||||
url: "/doc",
|
||||
}),
|
||||
);
|
||||
|
||||
// ------- MIDDLEWARES -------
|
||||
app.use("*", poweredBy());
|
||||
|
|
@ -34,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!");
|
||||
});
|
||||
|
|
@ -57,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(
|
||||
|
|
@ -101,12 +193,14 @@ app.post(
|
|||
const body = c.req.valid("json");
|
||||
|
||||
const sourcesOnly = query.sourcesOnly === "true";
|
||||
const spaces = query.spaces?.split(",") ?? [""];
|
||||
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
|
||||
|
|
@ -118,9 +212,9 @@ app.post(
|
|||
// SLICED to 5 to avoid too many queries
|
||||
for (const space of spaces.slice(0, 5)) {
|
||||
console.log("space", space);
|
||||
if (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(
|
|||
},
|
||||
);
|
||||
|
||||
// ERROR #1 - this is the api that the editor uses, it is just a scrape off of /api/chat so you may check that out
|
||||
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 response = await streamText({ model, prompt: `${request}-${context}`, maxTokens: 224 });
|
||||
|
||||
return response.toTextStreamResponse();
|
||||
})
|
||||
|
||||
export default app;
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ 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>
|
||||
${context}
|
||||
</context>
|
||||
|
||||
<context_score>
|
||||
score: ${score}
|
||||
normalisedScore: ${normalisedScore}
|
||||
</context_score>`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2020"],
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ node_compat = true
|
|||
|
||||
[[vectorize]]
|
||||
binding = "VECTORIZE_INDEX"
|
||||
index_name = "supermem-vector"
|
||||
index_name = "supermem-vector-dev"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Editor, Tldraw, setUserPreferences, TLStoreWithStatus } from "tldraw";
|
||||
import { createAssetFromUrl } from "./lib/createAssetUrl";
|
||||
import "tldraw/tldraw.css";
|
||||
|
|
@ -7,10 +7,53 @@ 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';
|
||||
import { getAssetUrls } from "@tldraw/assets/selfHosted";
|
||||
import { memo } from "react";
|
||||
import DragContext from "./lib/context";
|
||||
import DropZone from "./dropComponent";
|
||||
|
||||
export const Canvas = memo(()=>{
|
||||
export const Canvas = memo(() => {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false);
|
||||
const Dragref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleDragOver = (event: any) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(true);
|
||||
console.log("entere")
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDraggingOver(false);
|
||||
console.log("leaver")
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const divElement = Dragref.current;
|
||||
if (divElement) {
|
||||
divElement.addEventListener('dragover', handleDragOver);
|
||||
divElement.addEventListener('dragleave', handleDragLeave);
|
||||
}
|
||||
return () => {
|
||||
if (divElement) {
|
||||
divElement.removeEventListener('dragover', handleDragOver);
|
||||
divElement.removeEventListener('dragleave', handleDragLeave);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DragContext.Provider value={{ isDraggingOver, setIsDraggingOver }}>
|
||||
<div
|
||||
ref={Dragref}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<TldrawComponent />
|
||||
</div>
|
||||
</DragContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
const TldrawComponent =memo(() => {
|
||||
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
|
||||
status: "loading",
|
||||
});
|
||||
|
|
@ -38,18 +81,22 @@ export const Canvas = memo(()=>{
|
|||
|
||||
setUserPreferences({ id: "supermemory", isDarkMode: true });
|
||||
|
||||
const assetUrls = getAssetUrls()
|
||||
const assetUrls = getAssetUrls();
|
||||
return (
|
||||
<Tldraw
|
||||
assetUrls={assetUrls}
|
||||
components={components}
|
||||
store={storeWithStatus}
|
||||
shapeUtils={[twitterCardUtil]}
|
||||
onMount={handleMount}
|
||||
>
|
||||
<div className="absolute left-1/2 top-0 z-[1000000] flex -translate-x-1/2 gap-2 bg-[#2C3439] text-[#B3BCC5]">
|
||||
<SaveStatus />
|
||||
</div>
|
||||
</Tldraw>
|
||||
<div className="w-full h-full">
|
||||
<Tldraw
|
||||
className="relative"
|
||||
assetUrls={assetUrls}
|
||||
components={components}
|
||||
store={storeWithStatus}
|
||||
shapeUtils={[twitterCardUtil]}
|
||||
onMount={handleMount}
|
||||
>
|
||||
<div className="absolute left-1/2 top-0 z-[1000000] flex -translate-x-1/2 gap-2 bg-[#2C3439] text-[#B3BCC5]">
|
||||
<SaveStatus />
|
||||
</div>
|
||||
<DropZone />
|
||||
</Tldraw>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ function page() {
|
|||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`h-screen w-full ${ !fullScreen && "px-4 py-6"} transition-all`}>
|
||||
<div className={`h-screen w-full ${ !fullScreen ? "px-4 py-6": "bg-[#1F2428]"} transition-all`}>
|
||||
<div>
|
||||
<PanelGroup className={` ${fullScreen ? "w-[calc(100vw-2rem)]" : "w-screen"} transition-all`} direction="horizontal">
|
||||
<Panel onExpand={()=> {setTimeout(()=> setFullScreen(false), 50)}} onCollapse={()=> {setTimeout(()=> setFullScreen(true), 50)}} defaultSize={30} collapsible={true} minSize={22}>
|
||||
|
|
|
|||
76
apps/web/app/(canvas)/dropComponent.tsx
Normal file
76
apps/web/app/(canvas)/dropComponent.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useRef, useCallback, useEffect, useContext } from "react";
|
||||
import { useEditor } from "tldraw";
|
||||
import DragContext, { DragContextType } from "./lib/context";
|
||||
import { handleExternalDroppedContent } from "./lib/createEmbeds";
|
||||
|
||||
const stripHtmlTags = (html: string): string => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
|
||||
const useDrag = (): DragContextType => {
|
||||
const context = useContext(DragContext);
|
||||
if (!context) {
|
||||
throw new Error('useCounter must be used within a CounterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
|
||||
function DropZone() {
|
||||
const dropRef = useRef<HTMLDivElement | null>(null);
|
||||
const {isDraggingOver, setIsDraggingOver} = useDrag();
|
||||
|
||||
const editor = useEditor();
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
const dt = event.dataTransfer;
|
||||
const items = dt.items;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i]!.kind === "file" && items[i]!.type.startsWith("image/")) {
|
||||
const file = items[i]!.getAsFile();
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target) {
|
||||
// setDroppedImage(e.target.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
} else if (items[i]!.kind === "string") {
|
||||
items[i]!.getAsString((data) => {
|
||||
const cleanText = stripHtmlTags(data);
|
||||
handleExternalDroppedContent({editor,text:cleanText})
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const divElement = dropRef.current;
|
||||
if (divElement) {
|
||||
// @ts-ignore
|
||||
divElement.addEventListener("drop", handleDrop);
|
||||
}
|
||||
return () => {
|
||||
if (divElement) {
|
||||
// @ts-ignore
|
||||
divElement.removeEventListener("drop", handleDrop);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-full w-full absolute top-0 left-0 z-[100000] pointer-events-none ${isDraggingOver && "bg-[#2C3439] pointer-events-auto"}`}
|
||||
ref={dropRef}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropZone;
|
||||
|
|
@ -7,12 +7,12 @@ export const components: Partial<TLUiComponents> = {
|
|||
TopPanel: null,
|
||||
DebugPanel: null,
|
||||
DebugMenu: null,
|
||||
PageMenu: null,
|
||||
// Minimap: null,
|
||||
// ContextMenu: null,
|
||||
// HelpMenu: null,
|
||||
// ZoomMenu: null,
|
||||
// StylePanel: null,
|
||||
// PageMenu: null,
|
||||
// NavigationPanel: null,
|
||||
// Toolbar: null,
|
||||
// KeyboardShortcutsDialog: null,
|
||||
|
|
|
|||
11
apps/web/app/(canvas)/lib/context.tsx
Normal file
11
apps/web/app/(canvas)/lib/context.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
export interface DragContextType {
|
||||
isDraggingOver: boolean;
|
||||
setIsDraggingOver: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
|
||||
const DragContext = createContext<DragContextType | undefined>(undefined);
|
||||
|
||||
export default DragContext;
|
||||
|
|
@ -2,8 +2,8 @@ import { AssetRecordType, Editor, TLAsset, TLAssetId, TLBookmarkShape, TLExterna
|
|||
|
||||
export default async function createEmbedsFromUrl({url, point, sources, editor}: {
|
||||
url: string
|
||||
point: VecLike | undefined
|
||||
sources: TLExternalContentSource[] | undefined
|
||||
point?: VecLike | undefined
|
||||
sources?: TLExternalContentSource[] | undefined
|
||||
editor: Editor
|
||||
}){
|
||||
|
||||
|
|
@ -50,10 +50,18 @@ export default async function createEmbedsFromUrl({url, point, sources, editor}:
|
|||
type: "url",
|
||||
url,
|
||||
});
|
||||
const fetchWebsite = await (await fetch(`https://unfurl-bookmark.pruthvirajthinks.workers.dev/?url=${url}`)).json()
|
||||
if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title;
|
||||
if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image;
|
||||
if (fetchWebsite.description) bookmarkAsset.props.description = fetchWebsite.description;
|
||||
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) {
|
||||
|
|
@ -79,6 +87,38 @@ export default async function createEmbedsFromUrl({url, point, sources, editor}:
|
|||
});
|
||||
}
|
||||
|
||||
function isURL(str: string) {
|
||||
try {
|
||||
new URL(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function handleExternalDroppedContent({text, editor}: {text:string, editor: Editor}){
|
||||
const position = editor.inputs.shiftKey
|
||||
? editor.inputs.currentPagePoint
|
||||
: editor.getViewportPageBounds().center;
|
||||
|
||||
if (isURL(text)){
|
||||
createEmbedsFromUrl({editor, url: text})
|
||||
} else{
|
||||
editor.createShape({
|
||||
type: "text",
|
||||
x: position.x - 75,
|
||||
y: position.y - 75,
|
||||
props: {
|
||||
text: text,
|
||||
size: "s",
|
||||
textAlign: "start",
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import QueryInput from "../home/queryinput";
|
||||
import { cn } from "@repo/ui/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
|
@ -19,7 +19,10 @@ 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";
|
||||
import { codeLanguageSubset } from "@/lib/constants";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
|
||||
function ChatWindow({
|
||||
q,
|
||||
|
|
@ -33,19 +36,85 @@ function ChatWindow({
|
|||
{
|
||||
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.`,
|
||||
// },
|
||||
],
|
||||
parts: [],
|
||||
sources: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const [isAutoScroll, setIsAutoScroll] = useState(true);
|
||||
|
||||
const removeJustificationFromText = (text: string) => {
|
||||
// remove everything after the first "<justification>" word
|
||||
const justificationLine = text.indexOf("<justification>");
|
||||
if (justificationLine !== -1) {
|
||||
// Add that justification to the last chat message
|
||||
const lastChatMessage = chatHistory[chatHistory.length - 1];
|
||||
if (lastChatMessage) {
|
||||
lastChatMessage.answer.justification = text.slice(justificationLine);
|
||||
}
|
||||
return text.slice(0, justificationLine);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const getAnswer = async (query: string, spaces: string[]) => {
|
||||
const sourcesFetch = await fetch(
|
||||
`/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chatHistory }),
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: handle this properly
|
||||
const sources = await sourcesFetch.json();
|
||||
|
||||
const sourcesZod = z.object({
|
||||
ids: z.array(z.string()),
|
||||
metadata: z.array(z.any()),
|
||||
});
|
||||
|
||||
const sourcesParsed = sourcesZod.safeParse(sources);
|
||||
|
||||
if (!sourcesParsed.success) {
|
||||
console.log(sources);
|
||||
console.error(sourcesParsed.error);
|
||||
toast.error("Something went wrong while getting the sources");
|
||||
return;
|
||||
}
|
||||
|
||||
setChatHistory((prevChatHistory) => {
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
const newChatHistory = [...prevChatHistory];
|
||||
const lastAnswer = newChatHistory[newChatHistory.length - 1];
|
||||
if (!lastAnswer) return prevChatHistory;
|
||||
const filteredSourceUrls = new Set(
|
||||
sourcesParsed.data.metadata.map((source) => source.url),
|
||||
);
|
||||
const uniqueSources = sourcesParsed.data.metadata.filter((source) => {
|
||||
if (filteredSourceUrls.has(source.url)) {
|
||||
filteredSourceUrls.delete(source.url);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
lastAnswer.answer.sources = uniqueSources.map((source) => ({
|
||||
title: source.title ?? "Untitled",
|
||||
type: source.type ?? "page",
|
||||
source: source.url ?? "https://supermemory.ai",
|
||||
content: source.description ?? "No content available",
|
||||
numChunks: sourcesParsed.data.metadata.filter(
|
||||
(f) => f.url === source.url,
|
||||
).length,
|
||||
}));
|
||||
return newChatHistory;
|
||||
});
|
||||
|
||||
const resp = await fetch(`/api/chat?q=${query}&spaces=${spaces}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ chatHistory }),
|
||||
|
|
@ -53,7 +122,6 @@ function ChatWindow({
|
|||
|
||||
const reader = resp.body?.getReader();
|
||||
let done = false;
|
||||
let result = "";
|
||||
while (!done && reader) {
|
||||
const { value, done: d } = await reader.read();
|
||||
done = d;
|
||||
|
|
@ -62,23 +130,28 @@ function ChatWindow({
|
|||
const newChatHistory = [...prevChatHistory];
|
||||
const lastAnswer = newChatHistory[newChatHistory.length - 1];
|
||||
if (!lastAnswer) return prevChatHistory;
|
||||
lastAnswer.answer.parts.push({ text: new TextDecoder().decode(value) });
|
||||
const txt = new TextDecoder().decode(value);
|
||||
|
||||
if (isAutoScroll) {
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
lastAnswer.answer.parts.push({ text: txt });
|
||||
return newChatHistory;
|
||||
});
|
||||
}
|
||||
|
||||
console.log(result);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (q.trim().length > 0) {
|
||||
setLayout("chat");
|
||||
getAnswer(
|
||||
q,
|
||||
spaces.map((s) => s.id),
|
||||
);
|
||||
setTimeout(() => {
|
||||
setLayout("chat");
|
||||
}, 300);
|
||||
} else {
|
||||
router.push("/home");
|
||||
}
|
||||
|
|
@ -94,18 +167,23 @@ function ChatWindow({
|
|||
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 />
|
||||
<QueryInput
|
||||
handleSubmit={() => {}}
|
||||
initialQuery={q}
|
||||
initialSpaces={[]}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div
|
||||
className="max-w-3xl flex mx-auto w-full flex-col mt-24"
|
||||
className="max-w-3xl relative flex mx-auto w-full flex-col mt-24 pb-32"
|
||||
key="chat"
|
||||
>
|
||||
{chatHistory.map((chat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b" : ""}`}
|
||||
className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b border-b-gray-400" : ""}`}
|
||||
>
|
||||
<h2
|
||||
className={cn(
|
||||
|
|
@ -151,15 +229,25 @@ function ChatWindow({
|
|||
</>
|
||||
))}
|
||||
{chat.answer.sources.map((source, idx) => (
|
||||
<div
|
||||
<Link
|
||||
href={source.source}
|
||||
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 className="flex justify-between text-foreground-menu text-sm">
|
||||
<span>{source.type}</span>
|
||||
|
||||
{source.numChunks > 1 && (
|
||||
<span>{source.numChunks} chunks</span>
|
||||
)}
|
||||
</div>
|
||||
<div>{source.title}</div>
|
||||
</div>
|
||||
<div className="text-base">{source.title}</div>
|
||||
<div className="text-xs">
|
||||
{source.content.length > 100
|
||||
? source.content.slice(0, 100) + "..."
|
||||
: source.content}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
|
@ -197,14 +285,67 @@ function ChatWindow({
|
|||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{chat.answer.parts.map((part) => part.text).join("")}
|
||||
{removeJustificationFromText(
|
||||
chat.answer.parts.map((part) => part.text).join(""),
|
||||
)}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Justification */}
|
||||
{chat.answer.justification &&
|
||||
chat.answer.justification.length && (
|
||||
<div
|
||||
className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`}
|
||||
>
|
||||
<Accordion defaultValue={""} type="single" collapsible>
|
||||
<AccordionItem value="justification">
|
||||
<AccordionTrigger className="text-foreground-menu">
|
||||
Justification
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar"
|
||||
defaultChecked
|
||||
>
|
||||
{chat.answer.justification.length > 0
|
||||
? chat.answer.justification
|
||||
.replaceAll("<justification>", "")
|
||||
.replaceAll("</justification>", "")
|
||||
: "No justification provided."}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="fixed bottom-0 w-full max-w-3xl pb-4">
|
||||
<QueryInput
|
||||
mini
|
||||
className="w-full shadow-md"
|
||||
initialQuery={""}
|
||||
initialSpaces={[]}
|
||||
handleSubmit={async (q, spaces) => {
|
||||
setChatHistory((prevChatHistory) => {
|
||||
return [
|
||||
...prevChatHistory,
|
||||
{
|
||||
question: q,
|
||||
answer: {
|
||||
parts: [],
|
||||
sources: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
await getAnswer(
|
||||
q,
|
||||
spaces.map((s) => `${s.id}`),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { AddIcon } from "@repo/ui/icons";
|
|||
import Image from "next/image";
|
||||
|
||||
import { AnimatePresence, useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
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 { createMemory, createSpace } from "../actions/doers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { Space } from "../actions/types";
|
||||
import { getSpaces } from "../actions/fetchers";
|
||||
import { toast } from "sonner";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function DynamicIsland() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
|
@ -253,13 +254,39 @@ function PageForm({
|
|||
cancelfn: () => void;
|
||||
spaces: Space[];
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<div className="bg-secondary border border-muted-foreground px-4 py-3 rounded-2xl mt-2 flex flex-col gap-3">
|
||||
<form
|
||||
action={async (e: FormData) => {
|
||||
const content = e.get("content")?.toString();
|
||||
const space = e.get("space")?.toString();
|
||||
if (!content) {
|
||||
toast.error("Content is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const cont = await createMemory({
|
||||
content: content,
|
||||
spaces: space ? [space] : undefined,
|
||||
});
|
||||
|
||||
console.log(cont);
|
||||
setLoading(false);
|
||||
if (cont.success) {
|
||||
toast.success("Memory created");
|
||||
} else {
|
||||
toast.error("Memory creation failed");
|
||||
}
|
||||
}}
|
||||
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="space">
|
||||
Space
|
||||
</Label>
|
||||
<Select>
|
||||
<Select name="space">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Space" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -272,24 +299,28 @@ function PageForm({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div key={`${loading}-${pending}`}>
|
||||
{loading ? <div>Loading...</div> : "not loading"}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[#858B92]" htmlFor="name">
|
||||
Page Url
|
||||
</Label>
|
||||
<Input
|
||||
className="bg-[#2B3237] focus-visible:ring-0 border-none focus-visible:ring-offset-0"
|
||||
id="name"
|
||||
id="input"
|
||||
name="content"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div
|
||||
onClick={cancelfn}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#2B3237] px-2 py-1 rounded-xl cursor-pointer"
|
||||
>
|
||||
cancel
|
||||
</div>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React from "react";
|
||||
import Menu from "../menu";
|
||||
import Header from "../header";
|
||||
import QueryInput from "./queryinput";
|
||||
import { homeSearchParamsCache } from "@/app/helpers/lib/searchParams";
|
||||
import { getSpaces } from "@/app/actions/fetchers";
|
||||
"use client";
|
||||
|
||||
async function Page({
|
||||
import React, { useEffect, useState } from "react";
|
||||
import QueryInput from "./queryinput";
|
||||
import { homeSearchParamsCache } from "@/lib/searchParams";
|
||||
import { getSpaces } from "@/app/actions/fetchers";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
|
|
@ -13,12 +14,18 @@ async function Page({
|
|||
// TODO: use this to show a welcome page/modal
|
||||
const { firstTime } = homeSearchParamsCache.parse(searchParams);
|
||||
|
||||
let spaces = await getSpaces();
|
||||
const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
if (!spaces.success) {
|
||||
// TODO: handle this error properly.
|
||||
spaces.data = [];
|
||||
}
|
||||
useEffect(() => {
|
||||
getSpaces().then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setSpaces(res.data);
|
||||
}
|
||||
// TODO: HANDLE ERROR
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col">
|
||||
|
|
@ -26,7 +33,17 @@ async function Page({
|
|||
{/* <div className="">hi {firstTime ? 'first time' : ''}</div> */}
|
||||
|
||||
<div className="w-full pb-20">
|
||||
<QueryInput initialSpaces={spaces.data} />
|
||||
<QueryInput
|
||||
handleSubmit={(q, spaces) => {
|
||||
const newQ =
|
||||
"/chat?q=" +
|
||||
encodeURI(q) +
|
||||
(spaces ? "&spaces=" + JSON.stringify(spaces) : "");
|
||||
|
||||
push(newQ);
|
||||
}}
|
||||
initialSpaces={spaces}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ function QueryInput({
|
|||
initialQuery = "",
|
||||
initialSpaces = [],
|
||||
disabled = false,
|
||||
className,
|
||||
mini = false,
|
||||
handleSubmit,
|
||||
}: {
|
||||
initialQuery?: string;
|
||||
initialSpaces?: {
|
||||
|
|
@ -19,32 +22,14 @@ function QueryInput({
|
|||
name: string;
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
mini?: boolean;
|
||||
handleSubmit: (q: string, spaces: { id: number; name: string }[]) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState(initialQuery);
|
||||
|
||||
const [selectedSpaces, setSelectedSpaces] = useState<number[]>([]);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const parseQ = () => {
|
||||
// preparedSpaces is list of spaces selected by user, with id and name
|
||||
const preparedSpaces = initialSpaces
|
||||
.filter((x) => selectedSpaces.includes(x.id))
|
||||
.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
};
|
||||
});
|
||||
|
||||
const newQ =
|
||||
"/chat?q=" +
|
||||
encodeURI(q) +
|
||||
(selectedSpaces ? "&spaces=" + JSON.stringify(preparedSpaces) : "");
|
||||
|
||||
return newQ;
|
||||
};
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
initialSpaces.map((x) => ({
|
||||
|
|
@ -54,21 +39,43 @@ function QueryInput({
|
|||
[initialSpaces],
|
||||
);
|
||||
|
||||
const preparedSpaces = useMemo(
|
||||
() =>
|
||||
initialSpaces
|
||||
.filter((x) => selectedSpaces.includes(x.id))
|
||||
.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
};
|
||||
}),
|
||||
[selectedSpaces, initialSpaces],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-secondary rounded-t-[24px]">
|
||||
<div className={className}>
|
||||
<div
|
||||
className={`bg-secondary ${!mini ? "rounded-t-3xl" : "rounded-3xl"}`}
|
||||
>
|
||||
{/* input and action button */}
|
||||
<form action={async () => push(parseQ())} className="flex gap-4 p-3">
|
||||
<form
|
||||
action={async () => {
|
||||
handleSubmit(q, preparedSpaces);
|
||||
setQ("");
|
||||
}}
|
||||
className="flex gap-4 p-3"
|
||||
>
|
||||
<textarea
|
||||
name="q"
|
||||
cols={30}
|
||||
rows={4}
|
||||
rows={mini ? 2 : 4}
|
||||
className="bg-transparent pt-2.5 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-white duration-200 tracking-[3%] outline-none resize-none w-full p-4"
|
||||
placeholder="Ask your second brain..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (!e.shiftKey) push(parseQ());
|
||||
handleSubmit(q, preparedSpaces);
|
||||
setQ("");
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
|
|
@ -85,24 +92,29 @@ function QueryInput({
|
|||
<Image src={ArrowRightIcon} alt="Right arrow icon" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Divider />
|
||||
</div>
|
||||
{/* 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)))}
|
||||
placeholder="Focus on specific spaces..."
|
||||
emptyIndicator={
|
||||
<p className="text-center text-lg leading-10 text-gray-600 dark:text-gray-400">
|
||||
no results found.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!mini && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex items-center gap-6 p-2 h-auto bg-secondary rounded-b-3xl">
|
||||
<MultipleSelector
|
||||
key={options.length}
|
||||
disabled={disabled}
|
||||
defaultOptions={options}
|
||||
onChange={(e) =>
|
||||
setSelectedSpaces(e.map((x) => parseInt(x.value)))
|
||||
}
|
||||
placeholder="Focus on specific spaces..."
|
||||
emptyIndicator={
|
||||
<p className="text-center text-lg leading-10 text-gray-600 dark:text-gray-400">
|
||||
no results found.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Header from "./header";
|
||||
import Menu from "./menu";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "../helpers/server/auth";
|
||||
import { auth } from "../../server/auth";
|
||||
import { Toaster } from "@repo/ui/shadcn/sonner";
|
||||
|
||||
async function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
|
||||
import { Space } from "@/app/actions/types";
|
||||
import { Content } from "@/server/db/schema";
|
||||
import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
|
||||
import Image from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
function page() {
|
||||
const [filter, setFilter] = useState("All")
|
||||
const setFilterfn = (i:string) => setFilter(i)
|
||||
function Page() {
|
||||
const [filter, setFilter] = useState("All");
|
||||
const setFilterfn = (i: string) => setFilter(i);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [memoriesAndSpaces, setMemoriesAndSpaces] = useState<{
|
||||
memories: Content[];
|
||||
spaces: Space[];
|
||||
}>({ memories: [], spaces: [] });
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { success, data } = await getAllUserMemoriesAndSpaces();
|
||||
if (!success ?? !data) return;
|
||||
setMemoriesAndSpaces({ memories: data.memories, spaces: data.spaces });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
return (
|
||||
<div className="max-w-3xl min-w-3xl py-36 h-full flex mx-auto w-full flex-col gap-12">
|
||||
<h2 className="text-white w-full font-medium text-2xl text-left">
|
||||
|
|
@ -16,41 +33,50 @@ function page() {
|
|||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full relative">
|
||||
<input
|
||||
type="text"
|
||||
className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none"
|
||||
placeholder="search here..."
|
||||
/>
|
||||
<Image className="absolute top-1/2 -translate-y-1/2 left-2" src={SearchIcon} alt="Search icon" />
|
||||
</div>
|
||||
|
||||
<Filters filter={filter} setFilter={setFilterfn} />
|
||||
<div className="w-full relative">
|
||||
<input
|
||||
type="text"
|
||||
className=" w-full py-3 rounded-md text-lg pl-8 bg-[#1F2428] outline-none"
|
||||
placeholder="search here..."
|
||||
/>
|
||||
<Image
|
||||
className="absolute top-1/2 -translate-y-1/2 left-2"
|
||||
src={SearchIcon}
|
||||
alt="Search icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Filters filter={filter} setFilter={setFilterfn} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[#B3BCC5]">Spaces</div>
|
||||
<TabComponent title="AI Technologies" description="Resources 12" />
|
||||
<TabComponent title="Python Tricks" description="Resources 120" />
|
||||
<TabComponent title="JavaScript Hacks" description="Resources 14" />
|
||||
{memoriesAndSpaces.spaces.map((space) => (
|
||||
<TabComponent title={space.name} description={space.id.toString()} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[#B3BCC5]">Pages</div>
|
||||
<LinkComponent title="How to make a custom AI model?" url="https://google.com" />
|
||||
<LinkComponent title="GPT 5 Release Date" url="https://wth.com" />
|
||||
<LinkComponent title="Why @sama never use uppercase" url="https://tom.com" />
|
||||
{memoriesAndSpaces.memories.map((memory) => (
|
||||
<LinkComponent title={memory.title ?? "No title"} url={memory.url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabComponent({title, description}: {title:string, description:string}){
|
||||
function TabComponent({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center my-6">
|
||||
<div>
|
||||
<div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
|
||||
{title.slice(0,2).toUpperCase()}
|
||||
{title.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
|
|
@ -58,37 +84,50 @@ function TabComponent({title, description}: {title:string, description:string}){
|
|||
<div>{description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Image src={NextIcon} alt="Search icon" />
|
||||
<Image src={NextIcon} alt="Search icon" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function LinkComponent({title, url}: {title:string, url:string}){
|
||||
function LinkComponent({ title, url }: { title: string; url: string }) {
|
||||
return (
|
||||
<div className="flex items-center my-6">
|
||||
<div>
|
||||
<div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
|
||||
<Image src={UrlIcon} alt="Url icon" />
|
||||
<div>
|
||||
<div className="h-12 w-12 bg-[#1F2428] flex justify-center items-center rounded-md">
|
||||
<Image src={UrlIcon} alt="Url icon" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
<div className="text-lg text-[#fff]">{title}</div>
|
||||
<div>{url}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
<div className="text-lg text-[#fff]">{title}</div>
|
||||
<div>{url}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const FilterMethods = ["All", "Spaces", "Pages", "Notes"]
|
||||
function Filters({setFilter, filter}:{setFilter: (i:string)=> void, filter: string}){
|
||||
const FilterMethods = ["All", "Spaces", "Pages", "Notes"];
|
||||
function Filters({
|
||||
setFilter,
|
||||
filter,
|
||||
}: {
|
||||
setFilter: (i: string) => void;
|
||||
filter: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{FilterMethods.map((i)=> {
|
||||
return <div onClick={()=> setFilter(i)} className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`}>{i}</div>
|
||||
{FilterMethods.map((i) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => setFilter(i)}
|
||||
className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`}
|
||||
>
|
||||
{i}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default page;
|
||||
export default Page;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Magic from "./ui/magic";
|
||||
import CrazySpinner from "./ui/crazy-spinner";
|
||||
import Asksvg from "./ui/asksvg";
|
||||
|
|
@ -8,14 +8,11 @@ import Autocompletesvg from "./ui/autocompletesvg";
|
|||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useEditor } from "novel";
|
||||
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
|
||||
function Aigenerate() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
// generating -> can be converted to false, so we need to make sure the generation gets cancelled
|
||||
// visible
|
||||
|
||||
const { editor } = useEditor();
|
||||
const setGeneratingfn = (v: boolean) => setGenerating(v);
|
||||
|
|
@ -58,8 +55,9 @@ function Aigenerate() {
|
|||
}}
|
||||
className="absolute z-50 top-0"
|
||||
>
|
||||
<ToolBar setGeneratingfn={setGeneratingfn} editor={editor} />
|
||||
<div className="h-8 w-18rem bg-blue-600 blur-[16rem]" />
|
||||
{/* TODO: handle Editor not initalised, maybe with a loading state. */}
|
||||
<ToolBar setGeneratingfn={setGeneratingfn} editor={editor} />
|
||||
<div className="h-8 w-18rem bg-blue-600 blur-[16rem]" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -68,10 +66,22 @@ function Aigenerate() {
|
|||
export default Aigenerate;
|
||||
|
||||
const options = [
|
||||
<><Translatesvg />Translate</>,
|
||||
<><Rewritesvg />Change Tone</>,
|
||||
<><Asksvg />Ask Gemini</>,
|
||||
<><Autocompletesvg />Auto Complete</>
|
||||
<>
|
||||
<Translatesvg />
|
||||
Translate
|
||||
</>,
|
||||
<>
|
||||
<Rewritesvg />
|
||||
Change Tone
|
||||
</>,
|
||||
<>
|
||||
<Asksvg />
|
||||
Ask Gemini
|
||||
</>,
|
||||
<>
|
||||
<Autocompletesvg />
|
||||
Auto Complete
|
||||
</>,
|
||||
];
|
||||
|
||||
function ToolBar({
|
||||
|
|
@ -116,7 +126,7 @@ function ToolBar({
|
|||
)}
|
||||
</AnimatePresence>
|
||||
<div className="select-none flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none">
|
||||
{item}
|
||||
{item}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -135,43 +145,42 @@ async function AigenerateContent({
|
|||
}) {
|
||||
setGeneratingfn(true);
|
||||
|
||||
const {from, to} = editor.view.state.selection;
|
||||
const content = editor.view.state.selection.content();
|
||||
content.content.forEach((v, i)=> {
|
||||
v.forEach((v, i)=> {
|
||||
console.log(v.text)
|
||||
})
|
||||
})
|
||||
const { from, to } = editor.view.state.selection;
|
||||
|
||||
const transaction = editor.state.tr
|
||||
transaction.replaceRange(from, to, content)
|
||||
const slice = editor.state.selection.content();
|
||||
const text = editor.storage.markdown.serializer.serialize(slice.content);
|
||||
|
||||
editor.view.dispatch(transaction)
|
||||
const request = [
|
||||
"Translate to hindi written in english, do not write anything else",
|
||||
"change tone, improve the way be more formal",
|
||||
"ask, answer the question",
|
||||
"continue this, minimum 80 characters, do not repeat just continue don't use ... to denote start",
|
||||
];
|
||||
|
||||
// console.log(content)
|
||||
// content.map((v, i)=> console.log(v.content))
|
||||
const resp = await fetch("/api/editorai", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
context: text,
|
||||
request: request[idx],
|
||||
}),
|
||||
});
|
||||
|
||||
// const fragment = Fragment.fromArray(content);
|
||||
const reader = resp.body?.getReader();
|
||||
let done = false;
|
||||
let position = to;
|
||||
while (!done && reader) {
|
||||
const { value, done: d } = await reader.read();
|
||||
done = d;
|
||||
|
||||
// console.log(fragment)
|
||||
|
||||
// editor.view.state.selection.content().content.append(content)
|
||||
const decoded = new TextDecoder().decode(value);
|
||||
console.log(decoded);
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(position + 1, decoded)
|
||||
.run();
|
||||
position += decoded.length;
|
||||
}
|
||||
|
||||
setGeneratingfn(false);
|
||||
|
||||
|
||||
|
||||
// const genAI = new GoogleGenerativeAI("AIzaSyDGwJCP9SH5gryyvh65LJ6xTZ0SOdNvzyY");
|
||||
// const model = genAI.getGenerativeModel({ model: "gemini-pro"});
|
||||
|
||||
// const result = (await model.generateContent(`${ty}, ${query}`)).response.text();
|
||||
|
||||
// .insertContentAt(
|
||||
// {
|
||||
// from: from,
|
||||
// to: to,
|
||||
// },
|
||||
// result,
|
||||
// )
|
||||
// .run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,19 +15,20 @@ import Topbar from "./components/topbar";
|
|||
|
||||
const Editor = () => {
|
||||
const [initialContent, setInitialContent] = useState<null | JSONContent>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [saveStatus, setSaveStatus] = useState("Saved");
|
||||
const [charsCount, setCharsCount] = useState();
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const content = window.localStorage.getItem("novel-content");
|
||||
if (content) setInitialContent(JSON.parse(content));
|
||||
else setInitialContent(defaultEditorContent);
|
||||
}, []);
|
||||
|
||||
if (!initialContent) return null;
|
||||
if (!initialContent) return <>Loading...</>;
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-screen-xl">
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function Cta() {
|
|||
height={1405}
|
||||
priority
|
||||
draggable="false"
|
||||
className="absolute z-[-2] hidden select-none rounded-3xl bg-black md:block lg:w-[80%]"
|
||||
className="absolute z-[-2] hidden select-none rounded-3xl bg-background md:block lg:w-[80%]"
|
||||
/>
|
||||
<h1 className="z-20 mt-4 text-center text-5xl font-medium tracking-tight text-white">
|
||||
Your bookmarks are collecting dust.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import Cta from "./Cta";
|
|||
import { Toaster } from "@repo/ui/shadcn/toaster";
|
||||
import Features from "./Features";
|
||||
import Footer from "./footer";
|
||||
import { auth } from "../helpers/server/auth";
|
||||
import { auth } from "../../server/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "../helpers/server/db";
|
||||
import { space } from "../helpers/server/db/schema";
|
||||
import { db } from "../../server/db";
|
||||
import { contentToSpace, space, storedContent } from "../../server/db/schema";
|
||||
import { ServerActionReturnType } from "./types";
|
||||
import { auth } from "../helpers/server/auth";
|
||||
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,
|
||||
|
|
@ -41,3 +46,223 @@ export const createSpace = async (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../helpers/server/db";
|
||||
import { users } from "../helpers/server/db/schema";
|
||||
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 "../helpers/server/auth";
|
||||
import { auth } from "../../server/auth";
|
||||
|
||||
export const getSpaces = async (): ServerActionReturnType<Space[]> => {
|
||||
const data = await auth();
|
||||
|
|
@ -23,3 +28,115 @@ export const getSpaces = async (): ServerActionReturnType<Space[]> => {
|
|||
|
||||
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 },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export type Space = {
|
||||
id: number;
|
||||
name: string;
|
||||
numberOfMemories?: number;
|
||||
};
|
||||
|
||||
export type ServerActionReturnType<T> = Promise<{
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { GET, POST } from "../../helpers/server/auth";
|
||||
export { GET, POST } from "../../../server/auth";
|
||||
export const runtime = "edge";
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export async function POST(req: NextRequest) {
|
|||
);
|
||||
|
||||
const resp = await fetch(
|
||||
`https://new-cf-ai-backend.dhravya.workers.dev/api/chat?query=${query}&user=${session.user.email}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`,
|
||||
`${process.env.BACKEND_BASE_URL}/api/chat?query=${query}&user=${session.user.id}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`,
|
||||
|
|
|
|||
30
apps/web/app/api/editorai/route.ts
Normal file
30
apps/web/app/api/editorai/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
// ERROR #2 - This the the next function that calls the backend, I sometimes think this is redundency, but whatever
|
||||
// I have commented the auth code, It should not work in development, but it still does sometimes
|
||||
export async function POST(request: NextRequest) {
|
||||
// const d = await ensureAuth(request);
|
||||
// if (!d) {
|
||||
// return new Response("Unauthorized", { status: 401 });
|
||||
// }
|
||||
const res : {context: string, request: string} = await request.json()
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${process.env.BACKEND_BASE_URL}/api/editorai?context=${res.context}&request=${res.request}`);
|
||||
// this just checks if there are erros I am keeping it commented for you to better understand the important pieces
|
||||
// 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 },
|
||||
// );
|
||||
// }
|
||||
return new Response(resp.body, { status: 200 });
|
||||
} catch (error) {
|
||||
return new Response(`Error, ${error}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { db } from "../helpers/server/db";
|
||||
import { sessions, users } from "../helpers/server/db/schema";
|
||||
import { db } from "../../server/db";
|
||||
import { sessions, users } from "../../server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function ensureAuth(req: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "@/app/helpers/server/db";
|
||||
import { db } from "@/server/db";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { sessions, storedContent, users } from "@/app/helpers/server/db/schema";
|
||||
import { sessions, storedContent, users } from "@/server/db/schema";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
|
|||
.from(storedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(storedContent.user, session.user.id),
|
||||
eq(storedContent.userId, session.user.id),
|
||||
eq(storedContent.type, "twitter-bookmark"),
|
||||
),
|
||||
);
|
||||
|
|
@ -32,7 +32,7 @@ export async function GET(req: NextRequest) {
|
|||
.from(storedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(storedContent.user, session.user.id),
|
||||
eq(storedContent.userId, session.user.id),
|
||||
ne(storedContent.type, "twitter-bookmark"),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "@/app/helpers/server/db";
|
||||
import { db } from "@/server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sessions, users } from "@/app/helpers/server/db/schema";
|
||||
import { sessions, users } from "@/server/db/schema";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from "@/app/helpers/server/db";
|
||||
import { sessions, space, users } from "@/app/helpers/server/db/schema";
|
||||
import { db } from "@/server/db";
|
||||
import { sessions, space, users } from "@/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { db } from "@/app/helpers/server/db";
|
||||
import { db } from "@/server/db";
|
||||
import { and, eq, sql, inArray } from "drizzle-orm";
|
||||
import {
|
||||
contentToSpace,
|
||||
|
|
@ -6,10 +6,12 @@ import {
|
|||
storedContent,
|
||||
users,
|
||||
space,
|
||||
} from "@/app/helpers/server/db/schema";
|
||||
} from "@/server/db/schema";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMetaData } from "@/app/helpers/lib/get-metadata";
|
||||
import { getMetaData } from "@/lib/get-metadata";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
import { limit } from "@/app/actions/doers";
|
||||
import { LIMITS } from "@/lib/constants";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
|
|
@ -33,22 +35,13 @@ export async function POST(req: NextRequest) {
|
|||
storeToSpaces = [];
|
||||
}
|
||||
|
||||
const count = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)`.mapWith(Number),
|
||||
})
|
||||
.from(storedContent)
|
||||
.where(
|
||||
and(
|
||||
eq(storedContent.user, session.user.id),
|
||||
eq(storedContent.type, "page"),
|
||||
),
|
||||
);
|
||||
|
||||
if (count[0]!.count > 100) {
|
||||
if (!(await limit(session.user.id))) {
|
||||
return NextResponse.json(
|
||||
{ message: "Error", error: "Limit exceeded" },
|
||||
{ status: 499 },
|
||||
{
|
||||
message: "Error: Ratelimit exceeded",
|
||||
error: `You have exceeded the limit of ${LIMITS["page"]} pages.`,
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +55,7 @@ export async function POST(req: NextRequest) {
|
|||
baseUrl: metadata.baseUrl,
|
||||
image: metadata.image,
|
||||
savedAt: new Date(),
|
||||
user: session.user.id,
|
||||
userId: session.user.id,
|
||||
})
|
||||
.returning({ id: storedContent.id });
|
||||
|
||||
|
|
|
|||
134
apps/web/app/api/unfirlsite/route.ts
Normal file
134
apps/web/app/api/unfirlsite/route.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { load } from 'cheerio'
|
||||
import { AwsClient } from "aws4fetch";
|
||||
|
||||
import type { NextRequest } from "next/server";
|
||||
import { ensureAuth } from "../ensureAuth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
const r2 = new AwsClient({
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||
});
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
const d = await ensureAuth(request);
|
||||
if (!d) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (
|
||||
!process.env.R2_ACCESS_KEY_ID ||
|
||||
!process.env.R2_ACCOUNT_ID ||
|
||||
!process.env.R2_SECRET_ACCESS_KEY ||
|
||||
!process.env.R2_BUCKET_NAME
|
||||
) {
|
||||
return new Response(
|
||||
"Missing one or more R2 env variables: R2_ENDPOINT, R2_ACCESS_ID, R2_SECRET_KEY, R2_BUCKET_NAME. To get them, go to the R2 console, create and paste keys in a `.dev.vars` file in the root of this project.",
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const website = new URL(request.url).searchParams.get("website");
|
||||
|
||||
if (!website) {
|
||||
return new Response("Missing website", { status: 400 });
|
||||
}
|
||||
|
||||
const salt = () => Math.floor(Math.random() * 11);
|
||||
const encodeWebsite = `${encodeURIComponent(website)}${salt()}`;
|
||||
|
||||
try {
|
||||
// this returns the og image, description and title of website
|
||||
const response = await unfurl(website);
|
||||
|
||||
if (!response.image){
|
||||
return new Response(JSON.stringify(response))
|
||||
}
|
||||
|
||||
const imageUrl = await process.env.DEV_IMAGES.get(encodeWebsite)
|
||||
if (imageUrl){
|
||||
return new Response(JSON.stringify({
|
||||
image: imageUrl,
|
||||
title: response.title,
|
||||
description: response.description,
|
||||
}))
|
||||
}
|
||||
|
||||
const res = await fetch(`${response.image}`)
|
||||
const image = await res.blob();
|
||||
|
||||
const url = new URL(
|
||||
`https://${process.env.R2_BUCKET_NAME}.${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
);
|
||||
|
||||
url.pathname = encodeWebsite;
|
||||
url.searchParams.set("X-Amz-Expires", "3600");
|
||||
|
||||
const signedPuturl = await r2.sign(
|
||||
new Request(url, {
|
||||
method: "PUT",
|
||||
}),
|
||||
{
|
||||
aws: { signQuery: true },
|
||||
}
|
||||
);
|
||||
await fetch(signedPuturl.url, {
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
});
|
||||
|
||||
await process.env.DEV_IMAGES.put(encodeWebsite, `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`)
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
image: `${process.env.R2_PUBLIC_BUCKET_ADDRESS}/${encodeWebsite}`,
|
||||
title: response.title,
|
||||
description: response.description,
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return new Response(JSON.stringify({
|
||||
status: 500,
|
||||
error: error,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfurl(url: string) {
|
||||
const response = await fetch(url)
|
||||
if (response.status >= 400) {
|
||||
throw new Error(`Error fetching url: ${response.status}`)
|
||||
}
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType?.includes('text/html')) {
|
||||
throw new Error(`Content-type not right: ${contentType}`)
|
||||
}
|
||||
|
||||
const content = await response.text()
|
||||
const $ = load(content)
|
||||
|
||||
const og: { [key: string]: string | undefined } = {}
|
||||
const twitter: { [key: string]: string | undefined } = {}
|
||||
|
||||
// @ts-ignore, it just works so why care of type safety if someone has better way go ahead
|
||||
$('meta[property^=og:]').each((_, el) => (og[$(el).attr('property')!] = $(el).attr('content')))
|
||||
// @ts-ignore
|
||||
$('meta[name^=twitter:]').each((_, el) => (twitter[$(el).attr('name')!] = $(el).attr('content')))
|
||||
|
||||
const title = og['og:title'] ?? twitter['twitter:title'] ?? $('title').text() ?? undefined
|
||||
const description =
|
||||
og['og:description'] ??
|
||||
twitter['twitter:description'] ??
|
||||
$('meta[name="description"]').attr('content') ??
|
||||
undefined
|
||||
const image = og['og:image:secure_url'] ?? og['og:image'] ?? twitter['twitter:image'] ?? undefined
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { Button } from "@repo/ui/shadcn/button";
|
||||
import { auth, signIn, signOut } from "../helpers/server/auth";
|
||||
import { db } from "../helpers/server/db";
|
||||
import { auth, signIn, signOut } from "../../server/auth";
|
||||
import { db } from "../../server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { users } from "../helpers/server/db/schema";
|
||||
import { getThemeToggler } from "../helpers/lib/get-theme-button";
|
||||
import { users } from "../../server/db/schema";
|
||||
import { getThemeToggler } from "../../lib/get-theme-button";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
|
|
|
|||
13
apps/web/cf-env.d.ts
vendored
13
apps/web/cf-env.d.ts
vendored
|
|
@ -1,6 +1,17 @@
|
|||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv extends CloudflareEnv {}
|
||||
interface ProcessEnv extends CloudflareEnv {
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
AUTH_SECRET: string;
|
||||
R2_ENDPOINT: string;
|
||||
R2_ACCESS_KEY_ID: string;
|
||||
R2_SECRET_ACCESS_KEY: string;
|
||||
R2_PUBLIC_BUCKET_ADDRESS: string;
|
||||
R2_BUCKET_NAME: string;
|
||||
BACKEND_SECURITY_KEY: string;
|
||||
BACKEND_BASE_URL: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
8
apps/web/env.d.ts
vendored
8
apps/web/env.d.ts
vendored
|
|
@ -2,14 +2,6 @@
|
|||
// by running `wrangler types --env-interface CloudflareEnv env.d.ts`
|
||||
|
||||
interface CloudflareEnv {
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
AUTH_SECRET: string;
|
||||
R2_ENDPOINT: string;
|
||||
R2_ACCESS_ID: string;
|
||||
R2_SECRET_KEY: string;
|
||||
R2_BUCKET_NAME: string;
|
||||
BACKEND_SECURITY_KEY: string;
|
||||
STORAGE: R2Bucket;
|
||||
DATABASE: D1Database;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
export const LIMITS = {
|
||||
page: 100,
|
||||
tweet: 1000,
|
||||
note: 1000,
|
||||
};
|
||||
|
||||
export const codeLanguageSubset = [
|
||||
"python",
|
||||
"javascript",
|
||||
|
|
@ -103,11 +103,9 @@ export const storedContent = createTable(
|
|||
savedAt: int("savedAt", { mode: "timestamp" }).notNull(),
|
||||
baseUrl: text("baseUrl", { length: 255 }),
|
||||
ogImage: text("ogImage", { length: 255 }),
|
||||
type: text("type", { enum: ["note", "page", "twitter-bookmark"] }).default(
|
||||
"page",
|
||||
),
|
||||
type: text("type").default("page"),
|
||||
image: text("image", { length: 255 }),
|
||||
userId: int("user").references(() => users.id, {
|
||||
userId: text("user").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
},
|
||||
|
|
@ -119,6 +117,8 @@ export const storedContent = createTable(
|
|||
}),
|
||||
);
|
||||
|
||||
export type Content = typeof storedContent.$inferSelect;
|
||||
|
||||
export const contentToSpace = createTable(
|
||||
"contentToSpace",
|
||||
{
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@cloudflare/next-on-pages": "1",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"@cloudflare/workers-types": "^4.20240614.0",
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/tailwind-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
|
|
@ -45,7 +45,7 @@
|
|||
"@auth/drizzle-adapter": "^1.1.0",
|
||||
"@aws-sdk/client-s3": "^3.577.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.577.0",
|
||||
"@cloudflare/puppeteer": "^0.0.8",
|
||||
"@cloudflare/puppeteer": "^0.0.11",
|
||||
"@headlessui/react": "^2.0.4",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@hono/swagger-ui": "^0.2.2",
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
"@tldraw/assets": "^2.2.0",
|
||||
"@types/readline-sync": "^1.4.8",
|
||||
"ai": "^3.1.14",
|
||||
"aws4fetch": "^1.0.18",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"compromise": "^14.13.0",
|
||||
"drizzle-orm": "^0.30.10",
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-tweet": "^3.2.1",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ export const ChatHistoryZod = z.object({
|
|||
source: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
numChunks: z.number().optional().default(1),
|
||||
}),
|
||||
),
|
||||
justification: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,39 +2,37 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground: rgba(179, 188, 197, 1);
|
||||
--foreground-menu: rgba(106, 115, 125, 1);
|
||||
--background: rgba(23, 27, 31, 1);
|
||||
--secondary: rgba(31, 36, 40, 1);
|
||||
--primary: rgba(54, 157, 253, 1);
|
||||
--border: rgba(51, 57, 67, 1);
|
||||
:root {
|
||||
--foreground: rgba(179, 188, 197, 1);
|
||||
--foreground-menu: rgba(106, 115, 125, 1);
|
||||
--background: rgba(23, 27, 31, 1);
|
||||
--secondary: rgba(31, 36, 40, 1);
|
||||
--primary: rgba(54, 157, 253, 1);
|
||||
--border: rgba(51, 57, 67, 1);
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue