From 747306979ac0146824b5d8376211661b1fa57a38 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Tue, 18 Feb 2025 11:20:08 -0700 Subject: [PATCH] added a batch delete feature --- apps/backend/src/routes/actions.ts | 18 ++ apps/backend/src/routes/memories.ts | 93 ++++++-- .../app/components/memories/MemoriesPage.tsx | 208 +++++++++++++++--- .../app/components/memories/SharedCard.tsx | 106 ++++++--- apps/web/app/components/ui/checkbox.tsx | 28 +++ apps/web/app/lib/hooks/use-memories.tsx | 1 + 6 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 apps/web/app/components/ui/checkbox.tsx diff --git a/apps/backend/src/routes/actions.ts b/apps/backend/src/routes/actions.ts index c0801ada..deba952b 100644 --- a/apps/backend/src/routes/actions.ts +++ b/apps/backend/src/routes/actions.ts @@ -50,6 +50,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() }) ), async (c) => { + const startTime = performance.now(); + console.log("[chat] Starting request"); + const user = c.get("user"); if (!user) { return c.json({ error: "Unauthorized" }, 401); @@ -57,6 +60,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() const { messages, threadId } = await c.req.valid("json"); + console.log("[chat] Converting messages"); const unfilteredCoreMessages = convertToCoreMessages( (messages as Message[]) .filter((m) => m.content.length > 0) @@ -79,6 +83,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() (message) => message.content.length > 0 ); + console.log("[chat] Setting up DB and logger"); const db = database(c.env.HYPERDRIVE.connectionString); const { initLogger, wrapAISDKModel } = await import("braintrust"); @@ -101,6 +106,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() return c.json({ error: "Empty query" }, 400); } + console.log("[chat] Generating embeddings and creating thread"); + const embedStart = performance.now(); // Run embedding generation and thread creation in parallel const [{ data: embedding }, thread] = await Promise.all([ c.env.AI.run("@cf/baai/bge-base-en-v1.5", { text: queryText }), @@ -116,6 +123,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() .returning() : null, ]); + console.log(`[chat] Embedding generation took ${performance.now() - embedStart}ms`); const threadUuid = threadId || thread?.[0].uuid; @@ -123,6 +131,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() return c.json({ error: "Failed to generate embedding" }, 500); } + console.log("[chat] Performing semantic search"); + const searchStart = performance.now(); // Perform semantic search const similarity = sql`1 - (${cosineDistance(chunk.embeddings, embedding[0])})`; @@ -144,6 +154,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() .where(and(eq(documents.userId, user.id), sql`${similarity} > 0.4`)) .orderBy(desc(similarity)) .limit(5); + console.log(`[chat] Semantic search took ${performance.now() - searchStart}ms`); const cleanDocumentsForContext = finalResults.map((d) => ({ title: d.title, @@ -169,6 +180,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() } try { + console.log("[chat] Starting stream generation"); + const streamStart = performance.now(); const data = new StreamData(); // De-duplicate chunks by URL to avoid showing duplicate content const uniqueResults = finalResults.reduce((acc, current) => { @@ -224,6 +237,8 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() ], async onFinish(completion) { try { + console.log("[chat] Stream finished, updating thread"); + const updateStart = performance.now(); if (lastUserMessage) { lastUserMessage.content = typeof lastUserMessage.content === "string" @@ -257,12 +272,15 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() .set({ messages: newMessages }) .where(eq(chatThreads.uuid, threadUuid)); } + console.log(`[chat] Thread update took ${performance.now() - updateStart}ms`); } catch (error) { console.error("Failed to update thread:", error); } }, }); + console.log(`[chat] Stream generation took ${performance.now() - streamStart}ms`); + console.log(`[chat] Total request time: ${performance.now() - startTime}ms`); return result.toDataStreamResponse({ headers: { "Supermemory-Thread-Uuid": threadUuid ?? "", diff --git a/apps/backend/src/routes/memories.ts b/apps/backend/src/routes/memories.ts index aa35877b..48d16f74 100644 --- a/apps/backend/src/routes/memories.ts +++ b/apps/backend/src/routes/memories.ts @@ -128,15 +128,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>() documents: documents, }) .from(documents) - .leftJoin( - contentToSpace, - eq(documents.id, contentToSpace.contentId) - ) + .leftJoin(contentToSpace, eq(documents.id, contentToSpace.contentId)) .where( - and( - eq(documents.userId, user.id), - isNull(contentToSpace.contentId) - ) + and(eq(documents.userId, user.id), isNull(contentToSpace.contentId)) ) .orderBy(desc(documents.createdAt)) .limit(count) @@ -146,15 +140,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>() total: sql`count(*)`.as("total"), }) .from(documents) - .leftJoin( - contentToSpace, - eq(documents.id, contentToSpace.contentId) - ) + .leftJoin(contentToSpace, eq(documents.id, contentToSpace.contentId)) .where( - and( - eq(documents.userId, user.id), - isNull(contentToSpace.contentId) - ) + and(eq(documents.userId, user.id), isNull(contentToSpace.contentId)) ), ]); @@ -196,7 +184,7 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>() const db = database(c.env.HYPERDRIVE.connectionString); - let documentIdNum + let documentIdNum; try { documentIdNum = Number(id); @@ -209,7 +197,9 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>() .from(documents) .where( and( - documentIdNum ? or(eq(documents.uuid, id), eq(documents.id, documentIdNum)) : eq(documents.uuid, id), + documentIdNum + ? or(eq(documents.uuid, id), eq(documents.id, documentIdNum)) + : eq(documents.uuid, id), eq(documents.userId, user.id) ) ) @@ -230,6 +220,73 @@ const memories = new Hono<{ Variables: Variables; Bindings: Env }>() return c.json({ success: true }); } + ) + .post( + "/batch-delete", + zValidator( + "json", + z.object({ + ids: z.array(z.string()), + }) + ), + async (c) => { + const { ids } = c.req.valid("json"); + const user = c.get("user"); + + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = database(c.env.HYPERDRIVE.connectionString); + + try { + // First get all valid documents that belong to the user + const docs = await db + .select() + .from(documents) + .where( + and( + eq(documents.userId, user.id), + sql`${documents.uuid} = ANY(ARRAY[${ids}]::text[])` + ) + ); + + if (docs.length === 0) { + return c.json({ error: "No valid documents found" }, 404); + } + + const docIds = docs.map((doc) => doc.id); + + // Delete in transaction to ensure consistency + await db.transaction(async (tx) => { + await Promise.all([ + // Delete document entries + tx + .delete(documents) + .where( + and( + eq(documents.userId, user.id), + sql`${documents.uuid} = ANY(ARRAY[${ids}]::text[])` + ) + ), + // Delete space connections + tx + .delete(contentToSpace) + .where( + sql`${contentToSpace.contentId} = ANY(ARRAY[${docIds}]::int[])` + ), + ]); + }); + + return c.json({ + success: true, + deletedCount: docs.length, + }); + } catch (error) { + console.error("Batch delete error:", error); + return c.json({ error: "Failed to delete documents" }, 500); + } + } ); export default memories; diff --git a/apps/web/app/components/memories/MemoriesPage.tsx b/apps/web/app/components/memories/MemoriesPage.tsx index cf758b13..8b0dbdc9 100644 --- a/apps/web/app/components/memories/MemoriesPage.tsx +++ b/apps/web/app/components/memories/MemoriesPage.tsx @@ -5,8 +5,10 @@ import { Button } from "../ui/button"; import AddMemory from "./AddMemory"; import SharedCard from "./SharedCard"; +import { Trash2 } from "lucide-react"; import { Masonry, useInfiniteLoader } from "masonic"; import { useHydrated } from "remix-utils/use-hydrated"; +import { toast } from "sonner"; import { useMemories } from "~/lib/hooks/use-memories"; import { useSpaces } from "~/lib/hooks/use-spaces"; import { Memory } from "~/lib/types/memory"; @@ -25,11 +27,66 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr const { spaceId } = useParams(); const [selectedVariant, setSelectedVariant] = useState("All Memories"); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [isDeleting, setIsDeleting] = useState(false); - const { memories, isLoading, loadMore, hasMore } = useMemories(0, 20, spaceId); + const { memories, isLoading, loadMore, hasMore, mutate } = useMemories(0, 20, spaceId); const { spaces } = useSpaces(); + const handleToggleSelection = useCallback((uuid: string) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(uuid)) { + newSet.delete(uuid); + } else { + newSet.add(uuid); + } + return newSet; + }); + }, []); + + const handleBatchDelete = useCallback(async () => { + if (selectedItems.size === 0) return; + + const confirmed = window.confirm( + `Are you sure you want to delete ${selectedItems.size} item${selectedItems.size > 1 ? "s" : ""}?`, + ); + + if (!confirmed) return; + + setIsDeleting(true); + try { + const response = await fetch("/backend/v1/memories/batch-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selectedItems) }), + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to delete items"); + } + + const data = (await response.json()) as { success: boolean; deletedCount: number }; + toast.success( + `Successfully deleted ${data.deletedCount} item${data.deletedCount > 1 ? "s" : ""}`, + ); + + // Reset selection mode and clear selected items + setIsSelectionMode(false); + setSelectedItems(new Set()); + + // Refresh the memories list + mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete items"); + } finally { + setIsDeleting(false); + } + }, [selectedItems, mutate]); + const loadMoreItems = useCallback( (startIndex: number, stopIndex: number) => { if (!hasMore || isLoading) { @@ -109,7 +166,8 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr // Combine items and generate key const { items, key } = useMemo(() => { - const shouldShowSpaces = !isSpace && (selectedVariant === "All Memories" || selectedVariant === "Spaces"); + const shouldShowSpaces = + !isSpace && (selectedVariant === "All Memories" || selectedVariant === "Spaces"); const allItems = [ ...addButtonItem, ...(shouldShowSpaces ? spaceItems : []), @@ -127,16 +185,96 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr if (index === 0 && showAddButtons) { return ; } - return ; + if (data.type === "space") { + return ; + } + return ( + handleToggleSelection(data.uuid)} + /> + ); }, - [showAddButtons], + [showAddButtons, isSelectionMode, selectedItems, handleToggleSelection], ); const handleVariantClick = useCallback((variant: Variant) => { setSelectedVariant(variant); setIsMobileMenuOpen(false); + if (variant === "Spaces") { + setIsSelectionMode(false); + setSelectedItems(new Set()); + } }, []); + const SelectionControls = useMemo( + () => ( +
+ {selectedVariant !== "Spaces" && ( + <> + + {isSelectionMode && ( + <> + + + + )} + + )} +
+ ), + [ + isSelectionMode, + selectedItems.size, + isDeleting, + handleBatchDelete, + selectedVariant, + filteredMemories, + selectedItems, + ], + ); + const MobileVariantButton = useMemo( () => ( - ))} + {variants + .filter((variant) => !(isSpace && variant === "Spaces")) + .map((variant) => ( + + ))} ), @@ -182,19 +322,21 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr const DesktopVariantMenu = useMemo( () => (
- {variants.filter(variant => !(isSpace && variant === "Spaces")).map((variant) => ( - - ))} + {variants + .filter((variant) => !(isSpace && variant === "Spaces")) + .map((variant) => ( + + ))}
), [selectedVariant, isSpace], @@ -210,6 +352,8 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr {DesktopVariantMenu} + {SelectionControls} + : null; } -function SharedCard({ data }: { data: Memory }) { +interface SharedCardProps { + data: Memory; + isSelectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; +} + +export default function SharedCard({ + data, + isSelectionMode, + isSelected, + onToggleSelect, +}: SharedCardProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); + // Flatten the data if it's a nested array and get the first item + if (Array.isArray(data)) { + console.log("weird data here, will try flattening.", data); + data = data.flat(Infinity)[0]; + } + + const ContentRenderer = + renderContent[data.type as keyof typeof renderContent] || + (() => { + console.log("SharedCard data", data); + return ( +
+ Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type} +
+ ); + }); + // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { @@ -686,27 +719,14 @@ function SharedCard({ data }: { data: Memory }) { }, }); - // Flatten the data if it's a nested array and get the first item - if (Array.isArray(data)) { - console.log("weird data here, will try flattening.", data); - data = data.flat(Infinity)[0]; - } - - const ContentRenderer = - renderContent[data.type as keyof typeof renderContent] || - (() => { - console.log("SharedCard data", data); - return ( -
- Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type} -
- ); - }); - - const handleDelete = (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - deleteMutation.mutate(data.id); + const handleDelete = (e?: Event) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + if (window.confirm("Are you sure you want to delete this memory?")) { + deleteMutation.mutate(data.id); + } }; const handleMoveToSpace = (spaceId: string) => { @@ -716,10 +736,23 @@ function SharedCard({ data }: { data: Memory }) { }); }; + const handleClick = (e: React.MouseEvent) => { + if (isSelectionMode && onToggleSelect) { + e.preventDefault(); + onToggleSelect(); + return; + } + + // Normal navigation behavior + if (data.url) { + window.location.href = data.url; + } + }; + return ( -
{ switch (data.type) { case "note": @@ -730,13 +763,20 @@ function SharedCard({ data }: { data: Memory }) { return "border-accent/30"; } })(), + isSelectionMode && "hover:ring-2 hover:ring-primary", + isSelected && "ring-2 ring-primary bg-primary/5", )} + onClick={handleClick} > - - {data.type !== "space" && ( + {isSelectionMode ? ( +
+ +
+ ) : ( + data.type !== "space" && ( -
+ ) + )} + + ); } @@ -838,5 +878,3 @@ export const SpaceSelector = function SpaceSelector({ }; const MemoizedSpaceSelector = memo(SpaceSelector); - -export default SharedCard; diff --git a/apps/web/app/components/ui/checkbox.tsx b/apps/web/app/components/ui/checkbox.tsx new file mode 100644 index 00000000..25d39143 --- /dev/null +++ b/apps/web/app/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; + +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { cn } from "~/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/apps/web/app/lib/hooks/use-memories.tsx b/apps/web/app/lib/hooks/use-memories.tsx index b956c319..66a3d60d 100644 --- a/apps/web/app/lib/hooks/use-memories.tsx +++ b/apps/web/app/lib/hooks/use-memories.tsx @@ -271,5 +271,6 @@ export function useMemories(start = 0, count = 40, spaceId?: string) { hasMore: memoriesData ? memoriesData.nextCursor < memoriesData.total : false, total: memoriesData?.total ?? 0, addMemory: wrappedAddMemory, + mutate: () => queryClient.invalidateQueries({ queryKey: cacheKey }), }; }