diff options
| author | Dhravya Shah <[email protected]> | 2024-06-22 20:38:00 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-06-22 20:38:00 -0500 |
| commit | 47e7528f675c187b0a771bc4040e8d8108c5ef8e (patch) | |
| tree | 0a738b7a816f7b61865558be567c88d86db238c3 | |
| parent | delete packagelock (diff) | |
| parent | cleanup (diff) | |
| download | supermemory-47e7528f675c187b0a771bc4040e8d8108c5ef8e.tar.xz supermemory-47e7528f675c187b0a771bc4040e8d8108c5ef8e.zip | |
Merge pull request #79 from Dhravya/chathistory
addeed chathistory functionality
33 files changed, 1007 insertions, 626 deletions
diff --git a/apps/web/app/(canvas)/canvas.css b/apps/web/app/(canvas)/canvas.css index f674e48f..3e6700da 100644 --- a/apps/web/app/(canvas)/canvas.css +++ b/apps/web/app/(canvas)/canvas.css @@ -1,3 +1,3 @@ .tlui-dialog__overlay { - position: fixed; + position: fixed; } diff --git a/apps/web/app/(canvas)/canvas.tsx b/apps/web/app/(canvas)/canvas.tsx index 72a23c92..aaf89a06 100644 --- a/apps/web/app/(canvas)/canvas.tsx +++ b/apps/web/app/(canvas)/canvas.tsx @@ -12,43 +12,40 @@ import { getAssetUrls } from "@tldraw/assets/selfHosted"; import { memo } from "react"; import DragContext from "./lib/context"; import DropZone from "./dropComponent"; -import './canvas.css' +import "./canvas.css"; export const Canvas = memo(() => { const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false); - const Dragref = useRef<HTMLDivElement | null>(null) + const Dragref = useRef<HTMLDivElement | null>(null); const handleDragOver = (event: any) => { event.preventDefault(); setIsDraggingOver(true); - console.log("entere") + console.log("entere"); }; - + useEffect(() => { const divElement = Dragref.current; if (divElement) { - divElement.addEventListener('dragover', handleDragOver); + divElement.addEventListener("dragover", handleDragOver); } return () => { if (divElement) { - divElement.removeEventListener('dragover', handleDragOver); + divElement.removeEventListener("dragover", handleDragOver); } }; }, []); return ( <DragContext.Provider value={{ isDraggingOver, setIsDraggingOver }}> - <div - ref={Dragref} - className="w-full h-full" - > - <TldrawComponent /> - </div> + <div ref={Dragref} className="w-full h-full"> + <TldrawComponent /> + </div> </DragContext.Provider> ); }); -const TldrawComponent =memo(() => { +const TldrawComponent = memo(() => { const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({ status: "loading", }); @@ -94,4 +91,4 @@ const TldrawComponent =memo(() => { </Tldraw> </div> ); -}) +}); diff --git a/apps/web/app/(canvas)/dropComponent.tsx b/apps/web/app/(canvas)/dropComponent.tsx index a7898b5a..a14bd1f3 100644 --- a/apps/web/app/(canvas)/dropComponent.tsx +++ b/apps/web/app/(canvas)/dropComponent.tsx @@ -52,14 +52,14 @@ function DropZone() { console.log("leaver"); }; - useEffect(()=> { - setInterval(()=> { + useEffect(() => { + setInterval(() => { editor.selectAll(); const shapes = editor.getSelectedShapes(); - const text = shapes.filter((s) => s.type === "text") - console.log("hrhh", text) - },5000) - }, []) + const text = shapes.filter((s) => s.type === "text"); + console.log("hrhh", text); + }, 5000); + }, []); const handleDrop = useCallback((event: DragEvent) => { event.preventDefault(); @@ -111,19 +111,23 @@ function DropZone() { className={`h-full flex justify-center items-center w-full absolute top-0 left-0 z-[100000] pointer-events-none ${isDraggingOver && "bg-[#2c3439ad] pointer-events-auto"}`} ref={dropRef} > - { - isDraggingOver&& ( - <> - <div className="absolute top-4 left-8"><TopRight /></div> - <div className="absolute top-4 right-8"><TopLeft /></div> - <div className="absolute bottom-4 left-8"><BottomLeft /></div> - <div className="absolute bottom-4 right-8"><BottomRight /></div> + {isDraggingOver && ( + <> + <div className="absolute top-4 left-8"> + <TopRight /> + </div> + <div className="absolute top-4 right-8"> + <TopLeft /> + </div> + <div className="absolute bottom-4 left-8"> + <BottomLeft /> + </div> + <div className="absolute bottom-4 right-8"> + <BottomRight /> + </div> <h2 className="text-2xl">Drop here to add Content on Canvas</h2> - </> - ) - - } - + </> + )} </div> ); } diff --git a/apps/web/app/(canvas)/lib/createEmbeds.ts b/apps/web/app/(canvas)/lib/createEmbeds.ts index 0ac3c7a5..64eb0627 100644 --- a/apps/web/app/(canvas)/lib/createEmbeds.ts +++ b/apps/web/app/(canvas)/lib/createEmbeds.ts @@ -1,17 +1,34 @@ -import { AssetRecordType, Editor, TLAsset, TLAssetId, TLBookmarkShape, TLExternalContentSource, TLShapePartial, Vec, VecLike, createShapeId, getEmbedInfo, getHashForString } from "tldraw"; - -export default async function createEmbedsFromUrl({url, point, sources, editor}: { - url: string - point?: VecLike | undefined - sources?: TLExternalContentSource[] | undefined - editor: Editor -}){ - +import { + AssetRecordType, + Editor, + TLAsset, + TLAssetId, + TLBookmarkShape, + TLExternalContentSource, + TLShapePartial, + Vec, + VecLike, + createShapeId, + getEmbedInfo, + getHashForString, +} from "tldraw"; + +export default async function createEmbedsFromUrl({ + url, + point, + sources, + editor, +}: { + url: string; + point?: VecLike | undefined; + sources?: TLExternalContentSource[] | undefined; + editor: Editor; +}) { const position = - point ?? - (editor.inputs.shiftKey - ? editor.inputs.currentPagePoint - : editor.getViewportPageBounds().center); + point ?? + (editor.inputs.shiftKey + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center); if (url?.includes("x.com") || url?.includes("twitter.com")) { return editor.createShape({ @@ -20,71 +37,71 @@ export default async function createEmbedsFromUrl({url, point, sources, editor}: y: position.y - 150, props: { url: url }, }); - } - // try to paste as an embed first - const embedInfo = getEmbedInfo(url); + // try to paste as an embed first + const embedInfo = getEmbedInfo(url); - if (embedInfo) { - return editor.putExternalContent({ - type: "embed", - url: embedInfo.url, - point, - embed: embedInfo.definition, - }); - } + if (embedInfo) { + return editor.putExternalContent({ + type: "embed", + url: embedInfo.url, + point, + embed: embedInfo.definition, + }); + } - const assetId: TLAssetId = AssetRecordType.createId( - getHashForString(url), - ); - const shape = createEmptyBookmarkShape(editor, url, position); - - // Use an existing asset if we have one, or else else create a new one - let asset = editor.getAsset(assetId) as TLAsset; - let shouldAlsoCreateAsset = false; - if (!asset) { - shouldAlsoCreateAsset = true; - try { - const bookmarkAsset = await editor.getAssetForExternalContent({ - type: "url", - url, - }); - const fetchWebsite: { - title?: string; - image?: string; - description?: string; - } = await (await fetch(`/api/unfirlsite?website=${url}`, { - method: "POST" - })).json() - if (bookmarkAsset){ - if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title; - if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image; - if (fetchWebsite.description) bookmarkAsset.props.description = fetchWebsite.description; - } - if (!bookmarkAsset) throw Error("Could not create an asset"); - asset = bookmarkAsset; - } catch (e) { - console.log(e) - return; + const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url)); + const shape = createEmptyBookmarkShape(editor, url, position); + + // Use an existing asset if we have one, or else else create a new one + let asset = editor.getAsset(assetId) as TLAsset; + let shouldAlsoCreateAsset = false; + if (!asset) { + shouldAlsoCreateAsset = true; + try { + const bookmarkAsset = await editor.getAssetForExternalContent({ + type: "url", + url, + }); + const fetchWebsite: { + title?: string; + image?: string; + description?: string; + } = await ( + await fetch(`/api/unfirlsite?website=${url}`, { + method: "POST", + }) + ).json(); + if (bookmarkAsset) { + if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title; + if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image; + if (fetchWebsite.description) + bookmarkAsset.props.description = fetchWebsite.description; } + if (!bookmarkAsset) throw Error("Could not create an asset"); + asset = bookmarkAsset; + } catch (e) { + console.log(e); + return; } + } - editor.batch(() => { - if (shouldAlsoCreateAsset) { - editor.createAssets([asset]); - } + editor.batch(() => { + if (shouldAlsoCreateAsset) { + editor.createAssets([asset]); + } - editor.updateShapes([ - { - id: shape.id, - type: shape.type, - props: { - assetId: asset.id, - }, + editor.updateShapes([ + { + id: shape.id, + type: shape.type, + props: { + assetId: asset.id, }, - ]); - }); + }, + ]); + }); } function isURL(str: string) { @@ -115,17 +132,23 @@ function formatTextToRatio(text: string) { if (currentLine) { lines.push(currentLine); } - return {height: (lines.length+1)*18, width: maxLineWidth*10}; + return { height: (lines.length + 1) * 18, width: maxLineWidth * 10 }; } -export function handleExternalDroppedContent({text, editor}: {text:string, editor: Editor}){ +export function handleExternalDroppedContent({ + text, + editor, +}: { + text: string; + editor: Editor; +}) { const position = editor.inputs.shiftKey - ? editor.inputs.currentPagePoint - : editor.getViewportPageBounds().center; + ? editor.inputs.currentPagePoint + : editor.getViewportPageBounds().center; - if (isURL(text)){ - createEmbedsFromUrl({editor, url: text}) - } else{ + if (isURL(text)) { + createEmbedsFromUrl({ editor, url: text }); + } else { // editor.createShape({ // type: "text", // x: position.x - 75, @@ -136,66 +159,76 @@ export function handleExternalDroppedContent({text, editor}: {text:string, edito // textAlign: "start", // }, // }); - const {height, width} =formatTextToRatio(text) + const { height, width } = formatTextToRatio(text); editor.createShape({ type: "Textcard", - x: position.x - (width/2), - y: position.y - (height/2), - props: { content:text, extrainfo: "https://chatgpt.com/c/762cd44e-1752-495b-967a-aa3c23c6024a", w: width, h:height }, + x: position.x - width / 2, + y: position.y - height / 2, + props: { + content: text, + extrainfo: "https://chatgpt.com/c/762cd44e-1752-495b-967a-aa3c23c6024a", + w: width, + h: height, + }, }); } } function centerSelectionAroundPoint(editor: Editor, position: VecLike) { - // Re-position shapes so that the center of the group is at the provided point - const viewportPageBounds = editor.getViewportPageBounds() - let selectionPageBounds = editor.getSelectionPageBounds() - - if (selectionPageBounds) { - const offset = selectionPageBounds!.center.sub(position) - - editor.updateShapes( - editor.getSelectedShapes().map((shape) => { - const localRotation = editor.getShapeParentTransform(shape).decompose().rotation - const localDelta = Vec.Rot(offset, -localRotation) - return { - id: shape.id, - type: shape.type, - x: shape.x! - localDelta.x, - y: shape.y! - localDelta.y, - } - }) - ) - } - - // Zoom out to fit the shapes, if necessary - selectionPageBounds = editor.getSelectionPageBounds() - if (selectionPageBounds && !viewportPageBounds.contains(selectionPageBounds)) { - editor.zoomToSelection() - } + // Re-position shapes so that the center of the group is at the provided point + const viewportPageBounds = editor.getViewportPageBounds(); + let selectionPageBounds = editor.getSelectionPageBounds(); + + if (selectionPageBounds) { + const offset = selectionPageBounds!.center.sub(position); + + editor.updateShapes( + editor.getSelectedShapes().map((shape) => { + const localRotation = editor + .getShapeParentTransform(shape) + .decompose().rotation; + const localDelta = Vec.Rot(offset, -localRotation); + return { + id: shape.id, + type: shape.type, + x: shape.x! - localDelta.x, + y: shape.y! - localDelta.y, + }; + }), + ); + } + + // Zoom out to fit the shapes, if necessary + selectionPageBounds = editor.getSelectionPageBounds(); + if ( + selectionPageBounds && + !viewportPageBounds.contains(selectionPageBounds) + ) { + editor.zoomToSelection(); + } } export function createEmptyBookmarkShape( - editor: Editor, - url: string, - position: VecLike + editor: Editor, + url: string, + position: VecLike, ): TLBookmarkShape { - const partial: TLShapePartial = { - id: createShapeId(), - type: 'bookmark', - x: position.x - 150, - y: position.y - 160, - opacity: 1, - props: { - assetId: null, - url, - }, - } - - editor.batch(() => { - editor.createShapes([partial]).select(partial.id) - centerSelectionAroundPoint(editor, position) - }) - - return editor.getShape(partial.id) as TLBookmarkShape -}
\ No newline at end of file + const partial: TLShapePartial = { + id: createShapeId(), + type: "bookmark", + x: position.x - 150, + y: position.y - 160, + opacity: 1, + props: { + assetId: null, + url, + }, + }; + + editor.batch(() => { + editor.createShapes([partial]).select(partial.id); + centerSelectionAroundPoint(editor, position); + }); + + return editor.getShape(partial.id) as TLBookmarkShape; +} diff --git a/apps/web/app/(canvas)/lib/loadSnap.ts b/apps/web/app/(canvas)/lib/loadSnap.ts index a3d58b72..c6b748a9 100644 --- a/apps/web/app/(canvas)/lib/loadSnap.ts +++ b/apps/web/app/(canvas)/lib/loadSnap.ts @@ -1,6 +1,6 @@ import { createTLStore, defaultShapeUtils, loadSnapshot } from "tldraw"; import { twitterCardUtil } from "../twitterCard"; -import {textCardUtil} from "../textCard" +import { textCardUtil } from "../textCard"; export async function loadRemoteSnapshot() { const res = await fetch( "https://learning-cf.pruthvirajthinks.workers.dev/get/page3", @@ -9,6 +9,6 @@ export async function loadRemoteSnapshot() { const newStore = createTLStore({ shapeUtils: [...defaultShapeUtils, twitterCardUtil, textCardUtil], }); - loadSnapshot(newStore, snapshot) + loadSnapshot(newStore, snapshot); return newStore; -}
\ No newline at end of file +} diff --git a/apps/web/app/(canvas)/textCard.tsx b/apps/web/app/(canvas)/textCard.tsx index 4517c521..065c5ae1 100644 --- a/apps/web/app/(canvas)/textCard.tsx +++ b/apps/web/app/(canvas)/textCard.tsx @@ -1,4 +1,11 @@ -import { BaseBoxShapeUtil, HTMLContainer, TLBaseBoxShape, TLBaseShape, useIsEditing, useValue } from "tldraw"; +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseBoxShape, + TLBaseShape, + useIsEditing, + useValue, +} from "tldraw"; type ITextCardShape = TLBaseShape< "Textcard", @@ -18,26 +25,29 @@ export class textCardUtil extends BaseBoxShapeUtil<ITextCardShape> { } component(s: ITextCardShape) { - - const isEditing = useIsEditing(s.id) + const isEditing = useIsEditing(s.id); const isHoveringWhileEditingSameShape = useValue( - 'is hovering', + "is hovering", () => { - const { editingShapeId, hoveredShapeId } = this.editor.getCurrentPageState() - + const { editingShapeId, hoveredShapeId } = + this.editor.getCurrentPageState(); + if (editingShapeId && hoveredShapeId !== editingShapeId) { - const editingShape = this.editor.getShape(editingShapeId) - if (editingShape && this.editor.isShapeOfType<TLBaseBoxShape>(editingShape, 'embed')) { - return true + const editingShape = this.editor.getShape(editingShapeId); + if ( + editingShape && + this.editor.isShapeOfType<TLBaseBoxShape>(editingShape, "embed") + ) { + return true; } } - - return false + + return false; }, - [] - ) + [], + ); - const isInteractive = isEditing || isHoveringWhileEditingSameShape + const isInteractive = isEditing || isHoveringWhileEditingSameShape; return ( <HTMLContainer className="flex h-full w-full items-center justify-center"> <div @@ -48,7 +58,7 @@ export class textCardUtil extends BaseBoxShapeUtil<ITextCardShape> { zIndex: isInteractive ? "" : "-1", background: "#2C3439", borderRadius: "16px", - padding: "8px 14px" + padding: "8px 14px", }} > <h1 style={{ fontSize: "15px" }}>{s.props.content}</h1> diff --git a/apps/web/app/(dash)/chat/[chatid]/page.tsx b/apps/web/app/(dash)/chat/[chatid]/page.tsx new file mode 100644 index 00000000..e37ae07e --- /dev/null +++ b/apps/web/app/(dash)/chat/[chatid]/page.tsx @@ -0,0 +1,38 @@ +import { getFullChatThread } from "@/app/actions/fetchers"; +import { chatSearchParamsCache } from "@/lib/searchParams"; +import ChatWindow from "../chatWindow"; + +async function Page({ + params, + searchParams, +}: { + params: { chatid: string }; + searchParams: Record<string, string | string[] | undefined>; +}) { + const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams); + + let chat: Awaited<ReturnType<typeof getFullChatThread>>; + + try { + chat = await getFullChatThread(params.chatid); + } catch (e) { + const error = e as Error; + return <div>This page errored out: {error.message}</div>; + } + + if (!chat.success || !chat.data) { + console.error(chat.error); + return <div>Chat not found. Check the console for more details.</div>; + } + + return ( + <ChatWindow + q={q} + spaces={spaces} + initialChat={chat.data.length > 0 ? chat.data : undefined} + threadId={params.chatid} + /> + ); +} + +export default Page; diff --git a/apps/web/app/(dash)/chat/actions.ts b/apps/web/app/(dash)/chat/actions.ts deleted file mode 100644 index e69de29b..00000000 --- a/apps/web/app/(dash)/chat/actions.ts +++ /dev/null diff --git a/apps/web/app/(dash)/chat/chatWindow.tsx b/apps/web/app/(dash)/chat/chatWindow.tsx index 32fd1fce..8485d0b2 100644 --- a/apps/web/app/(dash)/chat/chatWindow.tsx +++ b/apps/web/app/(dash)/chat/chatWindow.tsx @@ -23,16 +23,18 @@ import { codeLanguageSubset } from "@/lib/constants"; import { z } from "zod"; import { toast } from "sonner"; import Link from "next/link"; +import { createChatObject } from "@/app/actions/doers"; +import { + ClipboardIcon, + ShareIcon, + SpeakerWaveIcon, +} from "@heroicons/react/24/outline"; +import { SendIcon } from "lucide-react"; function ChatWindow({ q, spaces, -}: { - q: string; - spaces: { id: string; name: string }[]; -}) { - const [layout, setLayout] = useState<"chat" | "initial">("initial"); - const [chatHistory, setChatHistory] = useState<ChatHistory[]>([ + initialChat = [ { question: q, answer: { @@ -40,8 +42,18 @@ function ChatWindow({ sources: [], }, }, - ]); - const [isAutoScroll, setIsAutoScroll] = useState(true); + ], + threadId, +}: { + q: string; + spaces: { id: string; name: string }[]; + initialChat?: ChatHistory[]; + threadId: string; +}) { + const [layout, setLayout] = useState<"chat" | "initial">( + initialChat.length > 1 ? "chat" : "initial", + ); + const [chatHistory, setChatHistory] = useState<ChatHistory[]>(initialChat); const removeJustificationFromText = (text: string) => { // remove everything after the first "<justification>" word @@ -61,7 +73,7 @@ function ChatWindow({ const getAnswer = async (query: string, spaces: string[]) => { const sourcesFetch = await fetch( - `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true`, + `/api/chat?q=${query}&spaces=${spaces}&sourcesOnly=true&threadId=${threadId}`, { method: "POST", body: JSON.stringify({ chatHistory }), @@ -79,79 +91,108 @@ function ChatWindow({ 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; } + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior: "smooth", + }); - 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; + // Assuming this is part of a larger function within a React component + const updateChatHistoryAndFetch = async () => { + // Step 1: Update chat history with the assistant's response + await new Promise((resolve) => { + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) { + resolve(undefined); + 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, + })); + + resolve(newChatHistory); + return newChatHistory; + }); }); - 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 }), - }); + // Step 2: Fetch data from the API + const resp = await fetch( + `/api/chat?q=${query}&spaces=${spaces}&threadId=${threadId}`, + { + method: "POST", + body: JSON.stringify({ chatHistory }), + }, + ); - const reader = resp.body?.getReader(); - let done = false; - while (!done && reader) { - const { value, done: d } = await reader.read(); - done = d; + // Step 3: Read the response stream and update the chat history + const reader = resp.body?.getReader(); + let done = false; + while (!done && reader) { + const { value, done: d } = await reader.read(); + if (d) { + setChatHistory((prevChatHistory) => { + createChatObject(threadId, prevChatHistory); + return prevChatHistory; + }); + } + done = d; - setChatHistory((prevChatHistory) => { - const newChatHistory = [...prevChatHistory]; - const lastAnswer = newChatHistory[newChatHistory.length - 1]; - if (!lastAnswer) return prevChatHistory; const txt = new TextDecoder().decode(value); + setChatHistory((prevChatHistory) => { + const newChatHistory = [...prevChatHistory]; + const lastAnswer = newChatHistory[newChatHistory.length - 1]; + if (!lastAnswer) return prevChatHistory; - if (isAutoScroll) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth", }); - } - lastAnswer.answer.parts.push({ text: txt }); - return newChatHistory; - }); - } + lastAnswer.answer.parts.push({ text: txt }); + return newChatHistory; + }); + } + }; + + updateChatHistoryAndFetch(); }; useEffect(() => { - if (q.trim().length > 0) { + if (q.trim().length > 0 || chatHistory.length > 0) { setLayout("chat"); - getAnswer( - q, - spaces.map((s) => s.id), - ); + const lastChat = chatHistory.length > 0 ? chatHistory.length - 1 : 0; + const startGenerating = chatHistory[lastChat]?.answer.parts[0]?.text + ? false + : true; + if (startGenerating) { + getAnswer( + q, + spaces.map((s) => `${s}`), + ); + } } else { router.push("/home"); } @@ -177,150 +218,207 @@ function ChatWindow({ </motion.div> ) : ( <div - className="max-w-3xl relative flex mx-auto w-full flex-col mt-24 pb-32" + className="max-w-3xl z-10 mx-auto relative h-full overflow-y-auto no-scrollbar" key="chat" > - {chatHistory.map((chat, idx) => ( - <div - key={idx} - className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b border-b-gray-400" : ""}`} - > - <h2 - className={cn( - "text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl", - )} - > - {chat.question} - </h2> - - <div className="flex flex-col gap-2 mt-2"> + <div className="w-full pt-24 mb-40"> + {chatHistory.map((chat, idx) => ( + <div key={idx} className="space-y-16"> <div - className={`${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`} + className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b border-b-gray-400" : ""}`} > - <Accordion - defaultValue={ - idx === chatHistory.length - 1 ? "memories" : "" - } - type="single" - collapsible + <h2 + className={cn( + "text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-xl", + )} > - <AccordionItem value="memories"> - <AccordionTrigger className="text-foreground-menu"> - Related Memories - </AccordionTrigger> - {/* TODO: fade out content on the right side, the fade goes away when the user scrolls */} - <AccordionContent - className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" - defaultChecked - > - {/* Loading state */} - {chat.answer.sources.length > 0 || - (chat.answer.parts.length === 0 && ( - <> - {[1, 2, 3, 4].map((_, idx) => ( - <div - key={`loadingState-${idx}`} - className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72 animate-pulse" - > - <div className="bg-slate-700 h-2 rounded-full w-1/2"></div> - <div className="bg-slate-700 h-2 rounded-full w-full"></div> - </div> - ))} - </> - ))} - {chat.answer.sources.map((source, idx) => ( - <Link - href={source.source} - key={idx} - className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72" - > - <div className="flex justify-between text-foreground-menu text-sm"> - <span>{source.type}</span> + {chat.question} + </h2> - {source.numChunks > 1 && ( - <span>{source.numChunks} chunks</span> - )} - </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> - </Accordion> - </div> - - {/* Summary */} - <div> - <div className="text-foreground-menu py-2">Summary</div> - <div className="text-base"> - {chat.answer.parts.length === 0 && ( - <div className="animate-pulse flex space-x-4"> - <div className="flex-1 space-y-3 py-1"> - <div className="h-2 bg-slate-700 rounded"></div> - <div className="h-2 bg-slate-700 rounded"></div> - </div> - </div> - )} - <Markdown - remarkPlugins={[remarkGfm, [remarkMath]]} - rehypePlugins={[ - rehypeKatex, - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - subset: codeLanguageSubset, - }, - ], - ]} - components={{ - code: code as any, - p: p as any, - }} - className="flex flex-col gap-2" - > - {removeJustificationFromText( - chat.answer.parts.map((part) => part.text).join(""), - )} - </Markdown> - </div> - </div> - {/* Justification */} - {chat.answer.justification && - chat.answer.justification.length && ( + <div className="flex flex-col"> + {/* Related memories */} <div - className={`${chat.answer.justification && chat.answer.justification.length > 0 ? "flex" : "hidden"}`} + className={`space-y-4 ${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`} > - <Accordion defaultValue={""} type="single" collapsible> - <AccordionItem value="justification"> + <Accordion + defaultValue={ + idx === chatHistory.length - 1 ? "memories" : "" + } + type="single" + collapsible + > + <AccordionItem value="memories"> <AccordionTrigger className="text-foreground-menu"> - Justification + Related Memories </AccordionTrigger> + {/* TODO: fade out content on the right side, the fade goes away when the user scrolls */} <AccordionContent - className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar" + className="flex items-center no-scrollbar overflow-auto gap-4 relative max-w-3xl no-scrollbar" defaultChecked > - {chat.answer.justification.length > 0 - ? chat.answer.justification - .replaceAll("<justification>", "") - .replaceAll("</justification>", "") - : "No justification provided."} + {/* Loading state */} + {chat.answer.sources.length > 0 || + (chat.answer.parts.length === 0 && ( + <> + {[1, 2, 3, 4].map((_, idx) => ( + <div + key={`loadingState-${idx}`} + className="w-[350px] shrink-0 p-4 gap-2 rounded-2xl flex flex-col bg-secondary animate-pulse" + > + <div className="bg-slate-700 h-2 rounded-full w-1/2"></div> + <div className="bg-slate-700 h-2 rounded-full w-full"></div> + </div> + ))} + </> + ))} + {chat.answer.sources.map((source, idx) => ( + <Link + href={source.source} + key={idx} + className="w-[350px] shrink-0 p-4 gap-2 rounded-2xl flex flex-col bg-secondary" + > + <div className="flex justify-between text-foreground-menu text-sm"> + <span>{source.type}</span> + + {source.numChunks > 1 && ( + <span>{source.numChunks} chunks</span> + )} + </div> + <div className="text-base"> + {source.title} + </div> + <div className="text-xs line-clamp-2"> + {source.content.length > 100 + ? source.content.slice(0, 100) + "..." + : source.content} + </div> + </Link> + ))} </AccordionContent> </AccordionItem> </Accordion> </div> - )} + + {/* Summary */} + <div> + <div className="text-foreground-menu py-2">Summary</div> + <div className="text-base"> + {/* Loading state */} + {(chat.answer.parts.length === 0 || + chat.answer.parts.join("").length === 0) && ( + <div className="animate-pulse flex space-x-4"> + <div className="flex-1 space-y-3 py-1"> + <div className="h-2 bg-slate-700 rounded"></div> + <div className="h-2 bg-slate-700 rounded"></div> + </div> + </div> + )} + + <Markdown + remarkPlugins={[remarkGfm, [remarkMath]]} + rehypePlugins={[ + rehypeKatex, + [ + rehypeHighlight, + { + detect: true, + ignoreMissing: true, + subset: codeLanguageSubset, + }, + ], + ]} + components={{ + code: code as any, + p: p as any, + }} + className="flex flex-col gap-2 text-base" + > + {removeJustificationFromText( + chat.answer.parts + .map((part) => part.text) + .join(""), + )} + </Markdown> + + <div className="mt-3 relative -left-2 flex items-center gap-1"> + {/* TODO: speak response */} + {/* <button className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200"> + <SpeakerWaveIcon className="size-[18px] group-hover:text-primary" /> + </button> */} + {/* copy response */} + <button + onClick={() => + navigator.clipboard.writeText( + chat.answer.parts + .map((part) => part.text) + .join(""), + ) + } + className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200" + > + <ClipboardIcon className="size-[18px] group-hover:text-primary" /> + </button> + <button + onClick={async () => { + const isWebShareSupported = + navigator.share !== undefined; + if (isWebShareSupported) { + try { + await navigator.share({ + title: "Your Share Title", + text: "Your share text or description", + url: "https://your-url-to-share.com", + }); + } catch (e) { + console.error("Error sharing:", e); + } + } else { + console.error("web share is not supported!"); + } + }} + className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200" + > + <SendIcon className="size-[18px] group-hover:text-primary" /> + </button> + </div> + </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> - </div> - ))} + ))} + </div> - <div className="fixed bottom-0 w-full max-w-3xl pb-4"> + <div className="fixed bottom-4 w-full max-w-3xl"> <QueryInput mini className="w-full shadow-md" diff --git a/apps/web/app/(dash)/chat/page.tsx b/apps/web/app/(dash)/chat/page.tsx deleted file mode 100644 index 12b1bd2a..00000000 --- a/apps/web/app/(dash)/chat/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import ChatWindow from "./chatWindow"; -import { chatSearchParamsCache } from "../../helpers/lib/searchParams"; -import { ChevronDownIcon, ClipboardIcon, SpeakerWaveIcon } from '@heroicons/react/24/outline' -import Image from "next/image"; -import { ArrowRightIcon } from "@repo/ui/icons"; -import QueryInput from "@repo/ui/components/QueryInput"; -// @ts-expect-error -await import("katex/dist/katex.min.css"); - -function Page({ - searchParams, -}: { - searchParams: Record<string, string | string[] | undefined>; -}) { - const { firstTime, q, spaces } = chatSearchParamsCache.parse(searchParams); - - console.log(spaces); - - return ( - <div className="max-w-3xl z-10 mx-auto relative h-full overflow-y-auto no-scrollbar"> - {/* <ChatWindow q={q} spaces={[]} /> */} - - <div className="w-full pt-24 space-y-40"> - {/* single q&A */} - {Array.from({ length: 1 }).map((_, i) => ( - - <div key={i} className="space-y-16"> - - {/* header */} - <div> - {/* query */} - <h1 className="text-white text-xl">Why is Retrieval-Augmented Generation important?</h1> - </div> - - {/* response */} - <div className="space-y-10"> - - {/* related memories */} - <div className="space-y-4"> - {/* section header */} - <div className="flex items-center gap-3"> - <h1>Related memories</h1> - <button> - <ChevronDownIcon className="size-4 stroke-2" /> - </button> - </div> - - {/* section content */} - {/* collection of memories */} - <div className="flex items-center no-scrollbar overflow-auto gap-4"> - {/* related memory */} - {Array.from({ length: 3 }).map((_, i) => ( - <div key={i} className="w-[350px] shrink-0 p-4 gap-2 rounded-2xl flex flex-col bg-secondary"> - - <h3 className="text-[13px]">Webpage</h3> - <p className="line-clamp-2 text-white">What is RAG? - Retrieval-Augmented Generation Explained - AWS</p> - </div> - ))} - </div> - </div> - - {/* summary */} - <div className="space-y-4"> - {/* section header */} - <div className="flex items-center gap-3"> - <h1>Summary</h1> - <button> - <ChevronDownIcon className="size-4 stroke-2" /> - </button> - </div> - - {/* section content */} - <div> - <p className="text-white text-base"> - Retrieval-Augmented Generation is crucial because it combines the strengths of retrieval-based methods, ensuring relevance and accuracy, with generation-based models, enabling creativity and flexibility. By integrating retrieval mechanisms, it addresses data sparsity issues, improves content relevance, offers fine-tuned control over output, handles ambiguity, and allows for continual learning, making it highly adaptable and effective across various natural language processing tasks and domains. - </p> - - {/* response actions */} - <div className="mt-3 relative -left-2 flex items-center gap-1"> - {/* speak response */} - <button className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200"> - <SpeakerWaveIcon className="size-[18px] group-hover:text-primary" /> - </button> - {/* copy response */} - <button className="group h-8 w-8 flex justify-center items-center active:scale-75 duration-200"> - <ClipboardIcon className="size-[18px] group-hover:text-primary" /> - </button> - </div> - </div> - - </div> - - </div> - - </div> - ))} - - </div> - - <div className="fixed bottom-4 max-w-3xl w-full"> - <QueryInput /> - </div> - - </div> - ); -} - -export default Page; diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx index 98fafc7a..43db52f3 100644 --- a/apps/web/app/(dash)/dynamicisland.tsx +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -71,16 +71,13 @@ function DynamicIslandContent() { } const lastBtn = useRef<string>(); - useEffect(() => { - console.log(show); - }, [show]); useEffect(() => { document.addEventListener("keydown", (e) => { if (e.key === "Escape") { setshow(true); } - console.log(e.key, lastBtn.current); + if (e.key === "a" && lastBtn.current === "Alt") { setshow(false); } @@ -90,19 +87,15 @@ function DynamicIslandContent() { return ( <> {show ? ( - <div + <button onClick={() => setshow(!show)} - className="bg-secondary px-3 w-[2.23rem] overflow-hidden hover:w-[9.2rem] whitespace-nowrap py-2 rounded-3xl transition-[width] cursor-pointer" + className="bg-secondary p-2 text-[#989EA4] rounded-full flex items-center justify-between gap-2 px-4 h-10 pr-5 z-[999] shadow-md" > - <div className="flex gap-4 items-center"> - <Image src={AddIcon} alt="Add icon" /> - Add Content - </div> - </div> + <Image src={AddIcon} alt="add icon" /> + Add content + </button> ) : ( - <div> - <ToolBar cancelfn={cancelfn} /> - </div> + <ToolBar cancelfn={cancelfn} /> )} </> ); @@ -272,7 +265,6 @@ function PageForm({ spaces: space ? [space] : undefined, }); - console.log(cont); setLoading(false); if (cont.success) { toast.success("Memory created"); diff --git a/apps/web/app/(dash)/header.tsx b/apps/web/app/(dash)/header.tsx index 040097fa..91c00125 100644 --- a/apps/web/app/(dash)/header.tsx +++ b/apps/web/app/(dash)/header.tsx @@ -9,7 +9,6 @@ import DynamicIsland from "./dynamicisland"; function Header() { return ( <div className="p-4 relative z-30 h-16 flex items-center"> - <div className="w-full flex items-center justify-between"> <Link className="" href="/home"> <Image @@ -20,14 +19,7 @@ function Header() { </Link> <div className="fixed z-30 left-1/2 -translate-x-1/2 top-5"> - {/* <DynamicIsland /> */} - <button className="bg-secondary p-2 text-[#989EA4] rounded-full flex items-center justify-between gap-2 px-4 h-10 pr-5"> - <Image - src={AddIcon} - alt="add icon" - /> - Add content - </button> + <DynamicIsland /> </div> <button className="flex duration-200 items-center text-[#7D8994] hover:bg-[#1F2429] text-[13px] gap-2 px-3 py-2 rounded-xl"> @@ -35,7 +27,6 @@ function Header() { Start new chat </button> </div> - </div> ); } diff --git a/apps/web/app/(dash)/home/page.tsx b/apps/web/app/(dash)/home/page.tsx index bdf6a61e..b6cfd223 100644 --- a/apps/web/app/(dash)/home/page.tsx +++ b/apps/web/app/(dash)/home/page.tsx @@ -5,6 +5,7 @@ import QueryInput from "./queryinput"; import { homeSearchParamsCache } from "@/lib/searchParams"; import { getSpaces } from "@/app/actions/fetchers"; import { useRouter } from "next/navigation"; +import { createChatThread } from "@/app/actions/doers"; function Page({ searchParams, @@ -12,7 +13,8 @@ function Page({ searchParams: Record<string, string | string[] | undefined>; }) { // TODO: use this to show a welcome page/modal - const { firstTime } = homeSearchParamsCache.parse(searchParams); + // const { firstTime } = homeSearchParamsCache.parse(searchParams); + const { push } = useRouter(); const [spaces, setSpaces] = useState<{ id: number; name: string }[]>([]); @@ -20,13 +22,12 @@ function Page({ getSpaces().then((res) => { if (res.success && res.data) { setSpaces(res.data); + return; } // TODO: HANDLE ERROR }); }, []); - const { push } = useRouter(); - return ( <div className="max-w-3xl h-full justify-center flex mx-auto w-full flex-col"> {/* all content goes here */} @@ -34,13 +35,12 @@ function Page({ <div className="w-full pb-20"> <QueryInput - handleSubmit={(q, spaces) => { - const newQ = - "/chat?q=" + - encodeURI(q) + - (spaces ? "&spaces=" + JSON.stringify(spaces) : ""); + handleSubmit={async (q, spaces) => { + const threadid = await createChatThread(q); - push(newQ); + push( + `/chat/${threadid.data}?spaces=${JSON.stringify(spaces)}&q=${q}`, + ); }} initialSpaces={spaces} /> diff --git a/apps/web/app/(dash)/home/queryinput.tsx b/apps/web/app/(dash)/home/queryinput.tsx index 4fadfb6f..46038225 100644 --- a/apps/web/app/(dash)/home/queryinput.tsx +++ b/apps/web/app/(dash)/home/queryinput.tsx @@ -69,7 +69,7 @@ function QueryInput({ name="q" cols={30} 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" + className="bg-transparent pt-2.5 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-gray-200 duration-200 tracking-[3%] outline-none resize-none w-full p-4" placeholder="Ask your second brain..." onKeyDown={(e) => { if (e.key === "Enter") { @@ -85,7 +85,7 @@ function QueryInput({ <button type="submit" - onClick={e => e.preventDefault()} + onClick={(e) => e.preventDefault()} disabled={disabled} className="h-12 w-12 rounded-[14px] bg-[#21303D] all-center shrink-0 hover:brightness-125 duration-200 outline-none focus:outline focus:outline-primary active:scale-90" > diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index 4e1f6989..1666aa1c 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -13,16 +13,13 @@ async function Layout({ children }: { children: React.ReactNode }) { return ( <main className="h-screen flex flex-col"> - <div className="fixed top-0 left-0 w-full"> <Header /> </div> <Menu /> - <div className="w-full h-full"> - {children} - </div> + <div className="w-full h-full">{children}</div> <Toaster /> </main> diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index f40c22e4..23fa2bef 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,6 +1,11 @@ import React from "react"; import Image from "next/image"; -import { MemoriesIcon, ExploreIcon, HistoryIcon, CanvasIcon } from "@repo/ui/icons"; +import { + MemoriesIcon, + ExploreIcon, + HistoryIcon, + CanvasIcon, +} from "@repo/ui/icons"; import Link from "next/link"; function Menu() { diff --git a/apps/web/app/actions/doers.ts b/apps/web/app/actions/doers.ts index 6c7180d9..8833d5d2 100644 --- a/apps/web/app/actions/doers.ts +++ b/apps/web/app/actions/doers.ts @@ -2,7 +2,13 @@ import { revalidatePath } from "next/cache"; import { db } from "../../server/db"; -import { contentToSpace, space, storedContent } from "../../server/db/schema"; +import { + chatHistory, + chatThreads, + contentToSpace, + space, + storedContent, +} from "../../server/db/schema"; import { ServerActionReturnType } from "./types"; import { auth } from "../../server/auth"; import { Tweet } from "react-tweet/api"; @@ -10,6 +16,7 @@ import { getMetaData } from "@/lib/get-metadata"; import { and, eq, inArray, sql } from "drizzle-orm"; import { LIMITS } from "@/lib/constants"; import { z } from "zod"; +import { ChatHistory } from "@repo/shared-types"; export const createSpace = async ( input: string | FormData, @@ -266,3 +273,74 @@ export const createMemory = async (input: { }; } }; + +export const createChatThread = async ( + firstMessage: string, +): ServerActionReturnType<string> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + return { error: "Not authenticated", success: false }; + } + + const thread = await db + .insert(chatThreads) + .values({ + firstMessage, + userId: data.user.id, + }) + .returning({ id: chatThreads.id }) + .execute(); + + console.log(thread); + + if (!thread[0]) { + return { + success: false, + error: "Failed to create chat thread", + }; + } + + return { success: true, data: thread[0].id }; +}; + +export const createChatObject = async ( + threadId: string, + chatHistorySoFar: ChatHistory[], +): ServerActionReturnType<boolean> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + return { error: "Not authenticated", success: false }; + } + + const lastChat = chatHistorySoFar[chatHistorySoFar.length - 1]; + if (!lastChat) { + return { + success: false, + data: false, + error: "No chat object found", + }; + } + console.log("sources: ", lastChat.answer.sources); + + const saved = await db.insert(chatHistory).values({ + question: lastChat.question, + answer: lastChat.answer.parts.map((part) => part.text).join(""), + answerSources: JSON.stringify(lastChat.answer.sources), + threadId, + }); + + if (!saved) { + return { + success: false, + data: false, + error: "Failed to save chat object", + }; + } + + return { + success: true, + data: true, + }; +}; diff --git a/apps/web/app/actions/fetchers.ts b/apps/web/app/actions/fetchers.ts index dc71252e..708b82b2 100644 --- a/apps/web/app/actions/fetchers.ts +++ b/apps/web/app/actions/fetchers.ts @@ -1,8 +1,10 @@ "use server"; -import { eq, inArray, not, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, not, sql } from "drizzle-orm"; import { db } from "../../server/db"; import { + chatHistory, + chatThreads, Content, contentToSpace, storedContent, @@ -10,6 +12,8 @@ import { } from "../../server/db/schema"; import { ServerActionReturnType, Space } from "./types"; import { auth } from "../../server/auth"; +import { ChatHistory, SourceZod } from "@repo/shared-types"; +import { z } from "zod"; export const getSpaces = async (): ServerActionReturnType<Space[]> => { const data = await auth(); @@ -103,22 +107,27 @@ export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{ // 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 len = spacesWithoutUser.map((space) => space.id).length; + + if (len > 0) { + 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() @@ -140,3 +149,64 @@ export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{ data: { spaces: spacesWithoutUser, memories: contentNotInAnySpace }, }; }; + +export const getFullChatThread = async ( + threadId: string, +): ServerActionReturnType<ChatHistory[]> => { + const data = await auth(); + + if (!data || !data.user || !data.user.id) { + return { error: "Not authenticated", success: false }; + } + + const thread = await db.query.chatThreads.findFirst({ + where: and( + eq(chatThreads.id, threadId), + eq(chatThreads.userId, data.user.id), + ), + }); + + if (!thread) { + return { error: "Thread not found", success: false }; + } + + const allChatsInThisThread = await db.query.chatHistory + .findMany({ + where: and(eq(chatHistory.threadId, threadId)), + orderBy: asc(chatHistory.id), + }) + .execute(); + + const accumulatedChatHistory: ChatHistory[] = allChatsInThisThread.map( + (chat) => { + console.log("answer sources", chat.answerSources); + const sourceCheck = z + .array(SourceZod) + .safeParse(JSON.parse(chat.answerSources ?? "[]")); + + if (!sourceCheck.success || !sourceCheck.data) { + console.error("sourceCheck.error", sourceCheck.error); + throw new Error("Invalid source data"); + } + + const sources = sourceCheck.data; + + return { + question: chat.question, + answer: { + parts: [ + { + text: chat.answer ?? undefined, + }, + ], + sources: sources ?? [], + }, + }; + }, + ); + + return { + success: true, + data: accumulatedChatHistory, + }; +}; diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index c19ce92b..d0e53066 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,9 +1,5 @@ import { type NextRequest } from "next/server"; -import { - ChatHistory, - ChatHistoryZod, - convertChatHistoryList, -} from "@repo/shared-types"; +import { ChatHistoryZod, convertChatHistoryList } from "@repo/shared-types"; import { ensureAuth } from "../ensureAuth"; import { z } from "zod"; @@ -67,11 +63,8 @@ export async function POST(req: NextRequest) { }, ); - console.log("sourcesOnly", sourcesOnly); - if (sourcesOnly == "true") { const data = await resp.json(); - console.log("data", data); return new Response(JSON.stringify(data), { status: 200 }); } diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index ab071121..521e1fcb 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,7 +1,7 @@ import { type Config } from "drizzle-kit"; export default { - schema: "./app/helpers/server/db/schema.ts", + schema: "./server/db/schema.ts", dialect: "sqlite", driver: "d1", dbCredentials: { diff --git a/apps/web/lib/searchParams.ts b/apps/web/lib/searchParams.ts index 9899eaf7..6db718c2 100644 --- a/apps/web/lib/searchParams.ts +++ b/apps/web/lib/searchParams.ts @@ -16,11 +16,19 @@ export const chatSearchParamsCache = createSearchParamsCache({ firstTime: parseAsBoolean.withDefault(false), q: parseAsString.withDefault(""), spaces: parseAsArrayOf( - parseAsJson(() => - z.object({ - id: z.string(), - name: z.string(), - }), - ), + parseAsJson((c) => { + const valid = z + .object({ + id: z.string(), + name: z.string(), + }) + .safeParse(c); + + if (!valid.success) { + return null; + } + + return valid.data; + }), ).withDefault([]), }); diff --git a/apps/web/migrations/000_setup.sql b/apps/web/migrations/000_setup.sql index 0c151b98..7e4275b8 100644 --- a/apps/web/migrations/000_setup.sql +++ b/apps/web/migrations/000_setup.sql @@ -27,6 +27,23 @@ CREATE TABLE `authenticator` ( FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint +CREATE TABLE `chatHistory` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `threadId` text NOT NULL, + `question` text NOT NULL, + `answerParts` text, + `answerSources` text, + `answerJustification` text, + FOREIGN KEY (`threadId`) REFERENCES `chatThread`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `chatThread` ( + `id` text PRIMARY KEY NOT NULL, + `firstMessage` text NOT NULL, + `userId` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint CREATE TABLE `contentToSpace` ( `contentId` integer NOT NULL, `spaceId` integer NOT NULL, @@ -60,7 +77,7 @@ CREATE TABLE `storedContent` ( `ogImage` text(255), `type` text DEFAULT 'page', `image` text(255), - `user` integer, + `user` text, FOREIGN KEY (`user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint @@ -80,6 +97,8 @@ CREATE TABLE `verificationToken` ( ); --> statement-breakpoint CREATE UNIQUE INDEX `authenticator_credentialID_unique` ON `authenticator` (`credentialID`);--> statement-breakpoint +CREATE INDEX `chatHistory_thread_idx` ON `chatHistory` (`threadId`);--> statement-breakpoint +CREATE INDEX `chatThread_user_idx` ON `chatThread` (`userId`);--> statement-breakpoint CREATE UNIQUE INDEX `space_name_unique` ON `space` (`name`);--> statement-breakpoint CREATE INDEX `spaces_name_idx` ON `space` (`name`);--> statement-breakpoint CREATE INDEX `spaces_user_idx` ON `space` (`user`);--> statement-breakpoint diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index 20327dda..3e197cbd 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "4a568d9b-a0e6-44ed-946b-694e34b063f3", + "id": "349eea0d-f26e-4579-9c65-3982816b0c6c", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -191,6 +191,119 @@ }, "uniqueConstraints": {} }, + "chatHistory": { + "name": "chatHistory", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "threadId": { + "name": "threadId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answerParts": { + "name": "answerParts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answerSources": { + "name": "answerSources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "answerJustification": { + "name": "answerJustification", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "chatHistory_thread_idx": { + "name": "chatHistory_thread_idx", + "columns": ["threadId"], + "isUnique": false + } + }, + "foreignKeys": { + "chatHistory_threadId_chatThread_id_fk": { + "name": "chatHistory_threadId_chatThread_id_fk", + "tableFrom": "chatHistory", + "tableTo": "chatThread", + "columnsFrom": ["threadId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "chatThread": { + "name": "chatThread", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "firstMessage": { + "name": "firstMessage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chatThread_user_idx": { + "name": "chatThread_user_idx", + "columns": ["userId"], + "isUnique": false + } + }, + "foreignKeys": { + "chatThread_userId_user_id_fk": { + "name": "chatThread_userId_user_id_fk", + "tableFrom": "chatThread", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, "contentToSpace": { "name": "contentToSpace", "columns": { @@ -411,7 +524,7 @@ }, "user": { "name": "user", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index 90bb9df7..0babac49 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1718412145023, - "tag": "0000_absurd_pandemic", + "when": 1719075265633, + "tag": "0000_conscious_arachne", "breakpoints": true } ] diff --git a/apps/web/server/db/schema.ts b/apps/web/server/db/schema.ts index 1ff23c82..f54d2094 100644 --- a/apps/web/server/db/schema.ts +++ b/apps/web/server/db/schema.ts @@ -154,3 +154,40 @@ export type StoredSpace = typeof space.$inferSelect; export type ChachedSpaceContent = StoredContent & { space: number; }; + +export const chatThreads = createTable( + "chatThread", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + firstMessage: text("firstMessage").notNull(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (thread) => ({ + userIdx: index("chatThread_user_idx").on(thread.userId), + }), +); + +export const chatHistory = createTable( + "chatHistory", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + threadId: text("threadId") + .notNull() + .references(() => chatThreads.id, { onDelete: "cascade" }), + question: text("question").notNull(), + answer: text("answerParts"), // Single answer part as string + answerSources: text("answerSources"), // JSON stringified array of objects + answerJustification: text("answerJustification"), + }, + (history) => ({ + threadIdx: index("chatHistory_thread_idx").on(history.threadId), + }), +); + +export type ChatThread = typeof chatThreads.$inferSelect; +export type ChatHistory = typeof chatHistory.$inferSelect; diff --git a/package.json b/package.json index 56f44944..35b90102 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@aws-sdk/s3-request-presigner": "^3.577.0", "@cloudflare/puppeteer": "^0.0.11", "@headlessui/react": "^2.0.4", - "@heroicons/react": "^2.1.3", + "@heroicons/react": "^2.1.4", "@hono/swagger-ui": "^0.2.2", "@hookform/resolvers": "^3.4.2", "@iarna/toml": "^2.2.5", diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index d3f466e1..318684b7 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -1,18 +1,20 @@ import { z } from "zod"; +export const SourceZod = z.object({ + type: z.string(), + source: z.string(), + title: z.string(), + content: z.string(), + numChunks: z.number().optional().default(1), +}); + +export type Source = z.infer<typeof SourceZod>; + export const ChatHistoryZod = z.object({ question: z.string(), answer: z.object({ - parts: z.array(z.object({ text: z.string() })), - sources: z.array( - z.object({ - type: z.enum(["note", "page", "tweet"]), - source: z.string(), - title: z.string(), - content: z.string(), - numChunks: z.number().optional().default(1), - }), - ), + parts: z.array(z.object({ text: z.string().optional() })), + sources: z.array(SourceZod), justification: z.string().optional(), }), }); @@ -52,5 +54,8 @@ export function convertChatHistoryList( ); }); + // THE LAST ASSISTANT CONTENT WILL ALWAYS BE EMPTY, so we remove it + convertedChats.pop(); + return convertedChats; } diff --git a/packages/tailwind-config/globals.css b/packages/tailwind-config/globals.css index 93099e8d..d09b120b 100644 --- a/packages/tailwind-config/globals.css +++ b/packages/tailwind-config/globals.css @@ -211,12 +211,13 @@ body { width: 0px; } -::-moz-selection { /* Code for Firefox */ - color: #369DFD; - background: #21303D; +::-moz-selection { + /* Code for Firefox */ + color: #369dfd; + background: #21303d; } ::selection { - color: #369DFD; - background: #21303D; -}
\ No newline at end of file + color: #369dfd; + background: #21303d; +} diff --git a/packages/ui/components/QueryInput.tsx b/packages/ui/components/QueryInput.tsx index 7188d667..ba476dda 100644 --- a/packages/ui/components/QueryInput.tsx +++ b/packages/ui/components/QueryInput.tsx @@ -1,46 +1,46 @@ -import React from 'react' -import Divider from '../shadcn/divider' -import { ArrowRightIcon } from '../icons' -import Image from 'next/image' +import React from "react"; +import Divider from "../shadcn/divider"; +import { ArrowRightIcon } from "../icons"; +import Image from "next/image"; function QueryInput() { - return ( - <div> - <div className="bg-secondary rounded-[20px] h-[68 px]"> - {/* input and action button */} - <form className="flex gap-4 p-2.5"> - <textarea - name="q" - cols={30} - rows={4} - className="bg-transparent h-12 focus:h-[128px] no-scrollbar pt-3 px-2 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-white duration-200 tracking-[3%] outline-none resize-none w-full" - placeholder="Ask your second brain..." - // onKeyDown={(e) => { - // if (e.key === "Enter") { - // e.preventDefault(); - // if (!e.shiftKey) push(parseQ()); - // } - // }} - // onChange={(e) => setQ(e.target.value)} - // value={q} - // disabled={disabled} - /> + return ( + <div> + <div className="bg-secondary rounded-[20px] h-[68 px]"> + {/* input and action button */} + <form className="flex gap-4 p-2.5"> + <textarea + name="q" + cols={30} + rows={4} + className="bg-transparent h-12 focus:h-[128px] no-scrollbar pt-3 px-2 text-base placeholder:text-[#5D6165] text-[#9DA0A4] focus:text-white duration-200 tracking-[3%] outline-none resize-none w-full" + placeholder="Ask your second brain..." + // onKeyDown={(e) => { + // if (e.key === "Enter") { + // e.preventDefault(); + // if (!e.shiftKey) push(parseQ()); + // } + // }} + // onChange={(e) => setQ(e.target.value)} + // value={q} + // disabled={disabled} + /> - <button - // type="submit" - // onClick={e => e.preventDefault()} - // disabled={disabled} - className="h-12 w-12 rounded-[14px] bg-[#21303D] all-center shrink-0 hover:brightness-125 duration-200 outline-none focus:outline focus:outline-primary active:scale-90" - > - <Image src={ArrowRightIcon} alt="Right arrow icon" /> - </button> - </form> + <button + // type="submit" + // onClick={e => e.preventDefault()} + // disabled={disabled} + className="h-12 w-12 rounded-[14px] bg-[#21303D] all-center shrink-0 hover:brightness-125 duration-200 outline-none focus:outline focus:outline-primary active:scale-90" + > + <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"> */} - {/* <MultipleSelector + {/* <Divider /> */} + </div> + {/* selected sources */} + {/* <div className="flex items-center gap-6 p-2 h-auto bg-secondary"> */} + {/* <MultipleSelector key={options.length} disabled={disabled} defaultOptions={options} @@ -52,9 +52,9 @@ function QueryInput() { </p> } /> */} - {/* </div> */} - </div> - ) + {/* </div> */} + </div> + ); } -export default QueryInput
\ No newline at end of file +export default QueryInput; diff --git a/packages/ui/components/canvas/draggableComponent.tsx b/packages/ui/components/canvas/draggableComponent.tsx index f9531f96..d0832e81 100644 --- a/packages/ui/components/canvas/draggableComponent.tsx +++ b/packages/ui/components/canvas/draggableComponent.tsx @@ -51,14 +51,13 @@ function DraggableComponents({ setIsDragging(false); }; - return ( <div ref={containerRef} onDragEnd={handleDragEnd} onDragStart={handleDragStart} draggable - className={`flex gap-4 px-1 rounded-md text-[#989EA4] border-2 transition ${isDragging ? "border-blue-600": "border-[#1F2428]"}`} + className={`flex gap-4 px-1 rounded-md text-[#989EA4] border-2 transition ${isDragging ? "border-blue-600" : "border-[#1F2428]"}`} > <Image className="select-none" src={icon} alt={iconAlt} /> <div className="flex flex-col gap-2"> diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts index 88d4ebc0..cf2f0aaa 100644 --- a/packages/ui/icons/index.ts +++ b/packages/ui/icons/index.ts @@ -8,7 +8,7 @@ import SelectIcon from "./select.svg"; import SearchIcon from "./search.svg"; import NextIcon from "./nextarrow.svg"; import UrlIcon from "./url.svg"; -import CanvasIcon from "./canvas.svg"; +import CanvasIcon from "./canvas.svg"; import blockIcon from "./block.svg"; import LinkIcon from "./link.svg"; import AutocompleteIcon from "./autocomplete.svg"; @@ -33,5 +33,5 @@ export { AutocompleteIcon, BlockIcon, DragIcon, - SettingsIcon + SettingsIcon, }; diff --git a/packages/ui/shadcn/accordion.tsx b/packages/ui/shadcn/accordion.tsx index a5dedb19..b24ecde4 100644 --- a/packages/ui/shadcn/accordion.tsx +++ b/packages/ui/shadcn/accordion.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; -import { ChevronDown } from "lucide-react"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { cn } from "@repo/ui/lib/utils"; @@ -30,7 +30,8 @@ const AccordionTrigger = React.forwardRef< {...props} > {children} - <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> + + <ChevronDownIcon className="size-4 stroke-2 transition-transform duration-200" /> </AccordionPrimitive.Trigger> </AccordionPrimitive.Header> )); diff --git a/packages/ui/shadcn/switch.tsx b/packages/ui/shadcn/switch.tsx index 78a67682..3c246594 100644 --- a/packages/ui/shadcn/switch.tsx +++ b/packages/ui/shadcn/switch.tsx @@ -1,7 +1,7 @@ -"use client" +"use client"; -import * as React from "react" -import * as SwitchPrimitives from "@radix-ui/react-switch" +import * as React from "react"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "@repo/ui/lib/utils"; @@ -12,18 +12,18 @@ const Switch = React.forwardRef< <SwitchPrimitives.Root className={cn( "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", - className + className, )} {...props} ref={ref} > <SwitchPrimitives.Thumb className={cn( - "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", )} /> </SwitchPrimitives.Root> -)) -Switch.displayName = SwitchPrimitives.Root.displayName +)); +Switch.displayName = SwitchPrimitives.Root.displayName; -export { Switch } +export { Switch }; |