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
@ -193,6 +193,83 @@ export default function Home() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
async function handleRename(item: FileRow) {
|
||||||
const newName = window.prompt("New name (or full destination path):", item.name);
|
const newName = window.prompt("New name (or full destination path):", item.name);
|
||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
@ -200,13 +277,7 @@ export default function Home() {
|
|||||||
newName.startsWith("/")
|
newName.startsWith("/")
|
||||||
? newName
|
? newName
|
||||||
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
|
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
|
||||||
try {
|
await renameMutation.mutateAsync({ item, to, newName });
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy(item: FileRow) {
|
async function handleCopy(item: FileRow) {
|
||||||
@ -216,13 +287,10 @@ export default function Home() {
|
|||||||
newPath.startsWith("/")
|
newPath.startsWith("/")
|
||||||
? newPath
|
? newPath
|
||||||
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
|
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
|
||||||
try {
|
const newName = newPath.startsWith("/")
|
||||||
await postJSON("/api/files/copy", { from: item.path, to });
|
? newPath.split("/").filter(Boolean).pop() ?? item.name
|
||||||
toast.success("Copied");
|
: newPath;
|
||||||
if (!searching) filesQuery.refetch();
|
await copyMutation.mutateAsync({ item, to, newName });
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(item: FileRow) {
|
async function handleDelete(item: FileRow) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter";
|
||||||
|
|
||||||
type CollectionRow = {
|
type CollectionRow = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -219,11 +220,20 @@ export default function QdrantPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder for scatter/UMAP visualization (follow-up) */}
|
{/* Embedding scatter */}
|
||||||
<div className="border rounded p-3">
|
<div className="border rounded p-3 flex flex-col">
|
||||||
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
|
<div className="text-sm font-medium mb-2">Embedding Visualization</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="flex-1 min-h-[420px]">
|
||||||
UMAP + deck.gl scatter will render here after vectors are pulled. Toggle “Vectors: on” to include vectors in queries.
|
<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>
|
</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