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:
parent
7df19c6696
commit
c80d982c55
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
228
src/components/qdrant/embedding-scatter.tsx
Normal file
228
src/components/qdrant/embedding-scatter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user