From c80d982c55c6a1ebabfec0a302789cbcf767dbca Mon Sep 17 00:00:00 2001 From: nicholai Date: Sat, 13 Sep 2025 07:19:20 -0600 Subject: [PATCH] feat: optimistic rename/copy with rollback; Qdrant UMAP+deck.gl scatter with lasso selection; README and docs to follow; fixes for deck.gl typings --- src/app/page.tsx | 96 +++++++-- src/app/qdrant/page.tsx | 18 +- src/components/qdrant/embedding-scatter.tsx | 228 ++++++++++++++++++++ 3 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 src/components/qdrant/embedding-scatter.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 58429c2..28653c6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -192,6 +192,83 @@ export default function Home() { queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); }, }); + + // Optimistic rename mutation + const renameMutation = useMutation({ + mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { + return postJSON("/api/files/rename", { from: vars.item.path, to: vars.to }); + }, + onMutate: async ({ item, to, newName }) => { + await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); + const prev = queryClient.getQueryData(["files", path, page, perPage]) as + | { total: number; page: number; perPage: number; items: FileRow[] } + | undefined; + if (prev) { + const nextItems = prev.items.map((x) => + x.id === item.id ? { ...x, name: newName, path: to } : x, + ); + queryClient.setQueryData(["files", path, page, perPage], { + ...prev, + items: nextItems, + }); + } + return { prev }; + }, + onError: (error, _vars, ctx) => { + if (ctx?.prev) { + queryClient.setQueryData(["files", path, page, perPage], ctx.prev); + } + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + }, + onSuccess: () => { + toast.success("Renamed"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); + }, + }); + + // Optimistic copy mutation + const copyMutation = useMutation({ + mutationFn: async (vars: { item: FileRow; to: string; newName: string }) => { + return postJSON("/api/files/copy", { from: vars.item.path, to: vars.to }); + }, + onMutate: async ({ item, to, newName }) => { + await queryClient.cancelQueries({ queryKey: ["files", path, page, perPage] }); + const prev = queryClient.getQueryData(["files", path, page, perPage]) as + | { total: number; page: number; perPage: number; items: FileRow[] } + | undefined; + if (prev) { + const temp: FileRow = { + ...item, + id: `temp-${Date.now()}`, + name: newName, + path: to, + }; + const nextItems = [temp, ...(prev.items ?? [])]; + queryClient.setQueryData(["files", path, page, perPage], { + ...prev, + total: (prev.total ?? nextItems.length), + items: nextItems, + }); + } + return { prev }; + }, + onError: (error, _vars, ctx) => { + if (ctx?.prev) { + queryClient.setQueryData(["files", path, page, perPage], ctx.prev); + } + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + }, + onSuccess: () => { + toast.success("Copied"); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["files", path, page, perPage] }); + }, + }); async function handleRename(item: FileRow) { const newName = window.prompt("New name (or full destination path):", item.name); @@ -200,13 +277,7 @@ export default function Home() { newName.startsWith("/") ? newName : `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/"); - try { - await postJSON("/api/files/rename", { from: item.path, to }); - toast.success("Renamed"); - if (!searching) filesQuery.refetch(); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } + await renameMutation.mutateAsync({ item, to, newName }); } async function handleCopy(item: FileRow) { @@ -216,13 +287,10 @@ export default function Home() { newPath.startsWith("/") ? newPath : `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/"); - try { - await postJSON("/api/files/copy", { from: item.path, to }); - toast.success("Copied"); - if (!searching) filesQuery.refetch(); - } catch (err) { - toast.error(err instanceof Error ? err.message : String(err)); - } + const newName = newPath.startsWith("/") + ? newPath.split("/").filter(Boolean).pop() ?? item.name + : newPath; + await copyMutation.mutateAsync({ item, to, newName }); } async function handleDelete(item: FileRow) { diff --git a/src/app/qdrant/page.tsx b/src/app/qdrant/page.tsx index daa14cf..c560763 100644 --- a/src/app/qdrant/page.tsx +++ b/src/app/qdrant/page.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter"; type CollectionRow = { name: string; @@ -219,11 +220,20 @@ export default function QdrantPage() { - {/* Placeholder for scatter/UMAP visualization (follow-up) */} -
+ {/* Embedding scatter */} +
Embedding Visualization
-
- UMAP + deck.gl scatter will render here after vectors are pulled. Toggle “Vectors: on” to include vectors in queries. +
+ { + // Future: drill-through to the file using payload/path mapping via ES index + console.log("open point", p); + }} + onSelect={(sel) => { + console.log("selected", sel.length); + }} + />
diff --git a/src/components/qdrant/embedding-scatter.tsx b/src/components/qdrant/embedding-scatter.tsx new file mode 100644 index 0000000..ef4a585 --- /dev/null +++ b/src/components/qdrant/embedding-scatter.tsx @@ -0,0 +1,228 @@ +"use client"; +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as React from "react"; +import DeckGL from "@deck.gl/react"; +import { OrthographicView } from "@deck.gl/core"; +import { ScatterplotLayer } from "@deck.gl/layers"; +import { UMAP } from "umap-js"; +import { Button } from "@/components/ui/button"; + +export type EmbeddingPoint = { + id: string | number; + payload?: Record; + vector?: number[] | Record; +}; + +export function toDenseVector(v?: number[] | Record): number[] | undefined { + if (!v) return undefined; + if (Array.isArray(v)) return v as number[]; + const obj = v as Record; + const keys = Object.keys(obj); + // If keys are numeric strings, sort by numeric index + if (keys.every((k) => /^\d+$/.test(k))) { + return keys + .map((k) => Number(k)) + .sort((a, b) => a - b) + .map((i) => obj[String(i)]); + } + // Fallback: arbitrary order + return keys.map((k) => obj[k]); +} + +type Props = { + points: EmbeddingPoint[]; + maxPoints?: number; + onSelect?: (selected: EmbeddingPoint[]) => void; + onOpenPoint?: (p: EmbeddingPoint) => void; +}; + +type Projected = { + id: string | number; + x: number; + y: number; + payload?: Record; + raw: EmbeddingPoint; +}; + +export function EmbeddingScatter({ + points, + maxPoints = 2000, + onSelect, + onOpenPoint, +}: Props) { + const [projected, setProjected] = React.useState([]); + const deckRef = React.useRef(null); + const containerRef = React.useRef(null); + + // View state for pan/zoom + const [viewState, setViewState] = React.useState({ + target: [0, 0, 0], + zoom: 0, + minZoom: -5, + maxZoom: 20, + }); + + const [drag, setDrag] = React.useState<{ + startX: number; + startY: number; + x: number; + y: number; + active: boolean; + }>({ startX: 0, startY: 0, x: 0, y: 0, active: false }); + + // Compute UMAP embedding when points change + React.useEffect(() => { + const vecs: number[][] = []; + const selectedPoints: EmbeddingPoint[] = []; + + for (const p of points) { + const v = toDenseVector(p.vector); + if (v && v.length > 0) { + vecs.push(v); + selectedPoints.push(p); + if (vecs.length >= maxPoints) break; + } + } + + if (vecs.length === 0) { + setProjected([]); + return; + } + + // UMAP projection to 2D + // Keep settings fairly standard; tweak if needed + const umap = new UMAP({ nComponents: 2, nNeighbors: 15, minDist: 0.1, spread: 1.0 }); + const embedding = umap.fit(vecs) as number[][]; + + // Normalize embedding to a reasonable range + const xs = embedding.map((e) => e[0]); + const ys = embedding.map((e) => e[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + const norm = embedding.map(([x, y], i) => { + const px = (x - minX) / Math.max(1e-9, maxX - minX); + const py = (y - minY) / Math.max(1e-9, maxY - minY); + // Center around 0 with range roughly [-0.5, 0.5] + return { + id: selectedPoints[i].id, + x: px - 0.5, + y: py - 0.5, + payload: selectedPoints[i].payload, + raw: selectedPoints[i], + } as Projected; + }); + + setProjected(norm); + }, [points, maxPoints]); + + // Build deck.gl ScatterplotLayer + const layer = React.useMemo(() => { + return new ScatterplotLayer({ + id: "scatter", + data: projected, + getPosition: (d: Projected) => [d.x, d.y, 0], + getFillColor: [66, 133, 244, 160], + getRadius: 0.01, + radiusMinPixels: 2, + radiusMaxPixels: 8, + pickable: true, + onClick: (info: any, _event: any) => { + const d = info?.object as Projected | null; + if (d && onOpenPoint) onOpenPoint(d.raw); + }, + updateTriggers: { + data: projected, + }, + }); + }, [projected, onOpenPoint]); + + // Handle rectangle selection via deck.pickObjects over drag rect + function handleMouseDown(e: React.MouseEvent) { + if (!containerRef.current) return; + setDrag({ startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY, active: true }); + } + + function handleMouseMove(e: React.MouseEvent) { + if (!drag.active) return; + setDrag((d) => ({ ...d, x: e.clientX, y: e.clientY })); + } + + function handleMouseUp() { + if (!drag.active) return; + const rect = containerRef.current?.getBoundingClientRect(); + const deck = deckRef.current?.deck; + if (rect && deck) { + const x = Math.min(drag.startX, drag.x) - rect.left; + const y = Math.min(drag.startY, drag.y) - rect.top; + const width = Math.abs(drag.x - drag.startX); + const height = Math.abs(drag.y - drag.startY); + + if (width > 2 && height > 2) { + const picked = deck.pickObjects({ x, y, width, height, layerIds: ["scatter"] }) as Array<{ + object: Projected; + }>; + const selected = picked.map((p) => p.object.raw); + onSelect?.(selected); + } + } + setDrag({ startX: 0, startY: 0, x: 0, y: 0, active: false }); + } + + // Helpers to reset/fit view + function fitView() { + // Reset zoom/target + setViewState({ target: [0, 0, 0], zoom: 0, minZoom: -5, maxZoom: 20 }); + } + + return ( +
+
+ +
+ + {/* Selection rectangle */} + {drag.active && ( +
+ )} + + + setViewState(viewState as any) + } + layers={[layer]} + views={[ + new OrthographicView({ + id: "ortho", + flipY: true, // so y+ goes up on screen + }), + ]} + > + {/* content is fully rendered by layer */} + +
+ ); +}