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 */}
+
+
+ );
+}