added a batch delete feature

This commit is contained in:
Dhravya Shah 2025-02-18 11:20:08 -07:00
parent 3e1b4bb788
commit 747306979a
6 changed files with 370 additions and 84 deletions

View file

@ -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<number>`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 ?? "",

View file

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

View file

@ -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<Variant>("All Memories");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(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 <AddMemory isSpace={isSpace} />;
}
return <SharedCard data={data} />;
if (data.type === "space") {
return <SharedCard data={data} />;
}
return (
<SharedCard
data={data}
isSelectionMode={isSelectionMode}
isSelected={selectedItems.has(data.uuid)}
onToggleSelect={() => 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(
() => (
<div className="flex items-center gap-2 mb-4">
{selectedVariant !== "Spaces" && (
<>
<Button
variant={isSelectionMode ? "secondary" : "outline"}
onClick={() => {
setIsSelectionMode(!isSelectionMode);
if (!isSelectionMode) {
setSelectedItems(new Set());
}
}}
>
{isSelectionMode ? "Cancel Selection" : "Select Items"}
</Button>
{isSelectionMode && (
<>
<Button
variant="outline"
onClick={() => {
// Get all non-space items' UUIDs
const allUuids = filteredMemories
.filter((item) => item.type !== "space")
.map((item) => item.uuid);
// If all items are selected, clear selection
if (allUuids.every((uuid) => selectedItems.has(uuid))) {
setSelectedItems(new Set());
} else {
// Otherwise, select all items
setSelectedItems(new Set(allUuids));
}
}}
>
{filteredMemories.every(
(item) => item.type === "space" || selectedItems.has(item.uuid),
)
? "Deselect All"
: "Select All"}
</Button>
<Button
variant="destructive"
onClick={handleBatchDelete}
disabled={selectedItems.size === 0 || isDeleting}
>
<Trash2 className="w-4 h-4 mr-2" />
{isDeleting ? "Deleting..." : `Delete Selected (${selectedItems.size})`}
</Button>
</>
)}
</>
)}
</div>
),
[
isSelectionMode,
selectedItems.size,
isDeleting,
handleBatchDelete,
selectedVariant,
filteredMemories,
selectedItems,
],
);
const MobileVariantButton = useMemo(
() => (
<Button
@ -158,21 +296,23 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
)}
>
<div className="flex flex-col gap-2 p-2">
{variants.filter(variant => !(isSpace && variant === "Spaces")).map((variant) => (
<Button
key={variant}
variant="ghost"
onClick={() => handleVariantClick(variant)}
className={cn(
"text-muted-foreground hover:bg-accent hover:text-foreground text-base w-full py-4 rounded-lg transition-colors",
selectedVariant === variant
? "bg-accent text-foreground font-medium shadow-sm"
: "hover:shadow-sm",
)}
>
{variant}
</Button>
))}
{variants
.filter((variant) => !(isSpace && variant === "Spaces"))
.map((variant) => (
<Button
key={variant}
variant="ghost"
onClick={() => handleVariantClick(variant)}
className={cn(
"text-muted-foreground hover:bg-accent hover:text-foreground text-base w-full py-4 rounded-lg transition-colors",
selectedVariant === variant
? "bg-accent text-foreground font-medium shadow-sm"
: "hover:shadow-sm",
)}
>
{variant}
</Button>
))}
</div>
</div>
),
@ -182,19 +322,21 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
const DesktopVariantMenu = useMemo(
() => (
<div className="hidden md:flex">
{variants.filter(variant => !(isSpace && variant === "Spaces")).map((variant) => (
<Button
key={variant}
variant="ghost"
onClick={() => setSelectedVariant(variant)}
className={cn(
"text-muted-foreground hover:bg-accent hover:text-foreground text-base",
selectedVariant === variant ? "bg-accent text-foreground" : "",
)}
>
{variant}
</Button>
))}
{variants
.filter((variant) => !(isSpace && variant === "Spaces"))
.map((variant) => (
<Button
key={variant}
variant="ghost"
onClick={() => setSelectedVariant(variant)}
className={cn(
"text-muted-foreground hover:bg-accent hover:text-foreground text-base",
selectedVariant === variant ? "bg-accent text-foreground" : "",
)}
>
{variant}
</Button>
))}
</div>
),
[selectedVariant, isSpace],
@ -210,6 +352,8 @@ function MemoriesPage({ showAddButtons = true, isSpace = false }: MemoriesPagePr
{DesktopVariantMenu}
</div>
{SelectionControls}
<Masonry
key={key + "memories"}
id="memories-masonry"

View file

@ -3,10 +3,14 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useInView } from "react-intersection-observer";
import { TweetSkeleton } from "react-tweet";
import { useNavigate } from "@remix-run/react";
import { NotionIcon } from "../icons/IntegrationIcons";
import { CustomTwitterComp } from "../twitter/render-tweet";
import Loader from "../ui/Loader";
import { Button, ButtonProps } from "../ui/button";
import { Card } from "../ui/card";
import { Checkbox } from "../ui/checkbox";
import {
Command,
CommandEmpty,
@ -34,6 +38,7 @@ import { SpaceIcon } from "@supermemory/shared/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FastAverageColor } from "fast-average-color";
import { MenuIcon, TrashIcon } from "lucide-react";
import { FolderIcon, MoreVertical, Trash } from "lucide-react";
import { toast } from "sonner";
import { pastelColors } from "~/lib/constants/pastelColors";
import { typeIcons } from "~/lib/constants/typeIcons";
@ -41,7 +46,6 @@ import { ExtraSpaceMetaData, fetchSpaces } from "~/lib/hooks/use-spaces";
import { useTextOverflow } from "~/lib/hooks/use-text-overflow";
import { Memory, WebsiteMetadata } from "~/lib/types/memory";
import { cn } from "~/lib/utils";
import { useNavigate } from "@remix-run/react";
const { useTweet } = ReactTweet;
@ -615,10 +619,39 @@ export function FetchAndRenderContent({ content }: { content: string }) {
return memory ? <SharedCard data={memory} /> : 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 (
<div>
Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type}
</div>
);
});
// 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 (
<div>
Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type}
</div>
);
});
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 (
<div
<Card
className={cn(
`relative overflow-hidden rounded-3xl border bg-card`,
"group relative overflow-hidden rounded-3xl border",
(() => {
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}
>
<a href={`/content/${data.uuid}`}>
{data.type !== "space" && (
{isSelectionMode ? (
<div className="absolute right-2 top-2 z-50">
<Checkbox checked={isSelected} onCheckedChange={onToggleSelect} />
</div>
) : (
data.type !== "space" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-white/70 dark:bg-gray-900/70 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 shadow-sm/50 backdrop-blur-[2px] border border-gray-200/30 dark:border-gray-700/30">
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-white/70 dark:bg-gray-900/70 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 shadow-sm/50 backdrop-blur-[2px] border border-gray-200/30 dark:border-gray-700/30 opacity-0 group-hover:opacity-100 transition-opacity">
<MenuIcon className="h-4 w-4" />
</div>
</DropdownMenuTrigger>
@ -754,10 +794,10 @@ function SharedCard({ data }: { data: Memory }) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<ContentRenderer data={data} />
</a>
</div>
)
)}
<ContentRenderer data={data} />
</Card>
);
}
@ -838,5 +878,3 @@ export const SpaceSelector = function SpaceSelector({
};
const MemoizedSpaceSelector = memo(SpaceSelector);
export default SharedCard;

View file

@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View file

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