feat: optimistic rename/copy with rollback; Qdrant UMAP+deck.gl scatter with lasso selection; README and docs to follow; fixes for deck.gl typings

This commit is contained in:
nicholai 2025-09-13 07:19:20 -06:00
parent 7df19c6696
commit c80d982c55
3 changed files with 324 additions and 18 deletions

View File

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

View File

@ -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() {
</div>
</div>
{/* Placeholder for scatter/UMAP visualization (follow-up) */}
<div className="border rounded p-3">
{/* Embedding scatter */}
<div className="border rounded p-3 flex flex-col">
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
<div className="text-xs text-muted-foreground">
UMAP + deck.gl scatter will render here after vectors are pulled. Toggle Vectors: on to include vectors in queries.
<div className="flex-1 min-h-[420px]">
<EmbeddingScatter
points={(pointsQuery.data?.points as unknown as EmbeddingPoint[]) ?? []}
onOpenPoint={(p) => {
// 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);
}}
/>
</div>
</div>
</div>

View File

@ -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<string, unknown>;
vector?: number[] | Record<string, number>;
};
export function toDenseVector(v?: number[] | Record<string, number>): number[] | undefined {
if (!v) return undefined;
if (Array.isArray(v)) return v as number[];
const obj = v as Record<string, number>;
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<string, unknown>;
raw: EmbeddingPoint;
};
export function EmbeddingScatter({
points,
maxPoints = 2000,
onSelect,
onOpenPoint,
}: Props) {
const [projected, setProjected] = React.useState<Projected[]>([]);
const deckRef = React.useRef<any>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
// View state for pan/zoom
const [viewState, setViewState] = React.useState<any>({
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<Projected>({
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 (
<div
ref={containerRef}
className="relative w-full h-full border rounded overflow-hidden"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<div className="absolute z-10 right-2 top-2 flex gap-2">
<Button size="sm" variant="outline" onClick={fitView}>
Reset View
</Button>
</div>
{/* Selection rectangle */}
{drag.active && (
<div
className="absolute border-2 border-blue-500/80 bg-blue-500/10 pointer-events-none"
style={{
left: Math.min(drag.startX, drag.x) - (containerRef.current?.getBoundingClientRect().left ?? 0),
top: Math.min(drag.startY, drag.y) - (containerRef.current?.getBoundingClientRect().top ?? 0),
width: Math.abs(drag.x - drag.startX),
height: Math.abs(drag.y - drag.startY),
}}
/>
)}
<DeckGL
ref={deckRef as any}
initialViewState={viewState as any}
viewState={viewState as any}
controller={true}
onViewStateChange={({ viewState }: { viewState: unknown }) =>
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 */}
</DeckGL>
</div>
);
}