Merge branch 'codetorso' into kartik

This commit is contained in:
CodeTorso 2024-06-20 08:38:21 -06:00 committed by GitHub
commit af90b960af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1451 additions and 361 deletions

View file

@ -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

View file

@ -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"
},

View file

@ -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);
}
}

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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>`,
)

View file

@ -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"),
});

View file

@ -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,

View file

@ -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);

View file

@ -1,6 +1,7 @@
{
"compilerOptions": {
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"]
"types": ["@cloudflare/workers-types"],
"downlevelIteration": true
}
}

View file

@ -5,7 +5,7 @@ node_compat = true
[[vectorize]]
binding = "VECTORIZE_INDEX"
index_name = "supermem-vector"
index_name = "supermem-vector-dev"
[ai]
binding = "AI"

View file

@ -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 (

View file

@ -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";

View file

@ -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>
);
})

View file

@ -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}>

View 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;

View file

@ -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,

View 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;

View file

@ -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()

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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 }) {

View file

@ -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;

View file

@ -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();
}

View file

@ -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">

View file

@ -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.

View file

@ -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";

View file

@ -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}`,
};
}
};

View file

@ -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 },
};
};

View file

@ -1,6 +1,7 @@
export type Space = {
id: number;
name: string;
numberOfMemories?: number;
};
export type ServerActionReturnType<T> = Promise<{

View file

@ -1,2 +1,2 @@
export { GET, POST } from "../../helpers/server/auth";
export { GET, POST } from "../../../server/auth";
export const runtime = "edge";

View file

@ -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}`,

View 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}`)
}
}

View file

@ -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) {

View file

@ -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"),
),
);

View file

@ -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";

View file

@ -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";

View file

@ -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 });

View 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,
}
}

View file

@ -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
View file

@ -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
View file

@ -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;
}

View file

@ -1,3 +1,9 @@
export const LIMITS = {
page: 100,
tweet: 1000,
note: 1000,
};
export const codeLanguageSubset = [
"python",
"javascript",

View file

@ -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",
{

View file

@ -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",

View file

@ -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(),
}),
});

View file

@ -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 {