"use client";
/* Chunk map: a 2-D (PCA) view of the project's stored chunk vectors so you can SEE how your
knowledge base is laid out - which chunks cluster, which source each belongs to, and (with a
query) exactly what retrieval returns and how it connects to the query point. Reuses the same
React Flow canvas the workflow builder uses. Read-only: it never mutates the store. */
import "@xyflow/react/dist/style.css";
import {
Background, BackgroundVariant, Controls, Handle, MiniMap, Position, ReactFlow, ReactFlowProvider,
useEdgesState, useNodesState, useReactFlow, type Edge, type Node, type NodeProps,
} from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../icons";
import { Segmented } from "../primitives";
import { api, ChunkDetail, ChunkMapResult, ChunkPoint } from "@/lib/api";
// How many chunks to plot. More points = a fuller picture but slower to project (PCA/SVD); the
// backend clamps to a hard ceiling regardless of what's requested here.
const POINT_LIMITS = [200, 400, 800, 1500] as const;
// Distinct, canvas-friendly colors assigned to sources in order. Wraps if a project has more
// sources than colors (fine - the legend still disambiguates).
const PALETTE = ["#6ea8fe", "#f7a072", "#8fd694", "#c792ea", "#ffd166", "#6dd0d6", "#f78fb3", "#a0d995", "#c0a2f0", "#e0b0ff"];
const RETRIEVED = "#ffcc33"; // highlight for the query point + retrieved chunks
const HIDDEN_HANDLE = { opacity: 0, width: 1, height: 1, minWidth: 0, minHeight: 0, border: "none", pointerEvents: "none" as const };
// --- custom nodes (dots on the map) ---
function ChunkDot({ data }: NodeProps) {
const d = data as any;
const size = d.retrieved ? 17 : 12;
const ring = d.retrieved
? `0 0 0 2px var(--bg-1), 0 0 0 4px ${RETRIEVED}`
: d.selected
? "0 0 0 2px var(--fg-0)"
: "0 0 1px rgba(0,0,0,.45)";
return (
{/* Hidden handles so the query->hit / parent-group edges have anchor points. */}
{d.retrieved && (
{d.retrieved}
)}
);
}
function QueryMarker({ data }: NodeProps) {
return (
);
}
const NODE_TYPES = { chunk: ChunkDot, query: QueryMarker };
// --- main ---
export function ChunkMap({ project }: { project: any }) {
return (
);
}
function ChunkMapInner({ project }: { project: any }) {
const [q, setQ] = useState("");
const [mode, setMode] = useState<"vector" | "hybrid">("vector");
const [rerank, setRerank] = useState(false);
const [limit, setLimit] = useState(400);
const [folders, setFolders] = useState([]);
const [folder, setFolder] = useState("");
const [res, setRes] = useState(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const [selId, setSelId] = useState(null);
// Full text of the selected chunk, fetched on demand (the map payload carries only a preview).
const [detail, setDetail] = useState(null);
const [detailBusy, setDetailBusy] = useState(false);
const [detailErr, setDetailErr] = useState(false);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { fitView } = useReactFlow();
// Color per source id (stable within a load, keyed off the legend order).
const colorOf = useMemo(() => {
const m = new Map();
(res?.sources || []).forEach((s, i) => m.set(s.id, PALETTE[i % PALETTE.length]));
return (sid?: string | null) => (sid && m.get(sid)) || "var(--fg-2)";
}, [res]);
const load = useCallback(async (query?: string) => {
if (!project?.id) return;
setLoading(true);
setErr(null);
try {
const r = await api.chunkMap(project.id, {
query: query?.trim() || undefined,
folders: folder ? [folder] : undefined,
hybrid: mode === "hybrid",
rerank,
limit,
});
setRes(r);
setSelId(null);
} catch (e: any) {
setErr(e?.message || "Failed to build the chunk map.");
setRes(null);
} finally {
setLoading(false);
}
}, [project?.id, folder, mode, rerank, limit]);
useEffect(() => { if (project?.id) api.listFolders(project.id).then(setFolders).catch(() => {}); }, [project?.id]);
// Load the full map once on open (no query overlay yet).
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [project?.id]);
// Changing the point budget re-fetches immediately (keeping any applied query overlay). Skip the
// first run so this doesn't double-load on mount alongside the effect above.
const didMountLimit = useRef(false);
useEffect(() => {
if (!didMountLimit.current) { didMountLimit.current = true; return; }
if (project?.id) load(res?.query || undefined);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [limit]);
// Build the React Flow graph whenever the map RESULT changes (fresh load, query overlay, or a
// new point budget). Framing (fitView) lives ONLY here: re-fitting on selection would yank the
// user back out of whatever zoom they'd dialed in. Nodes start unselected — the selection effect
// below toggles the highlight ring in place without rebuilding or re-framing the graph.
useEffect(() => {
if (!res) { setNodes([]); setEdges([]); return; }
const ns: Node[] = res.points.map((p) => ({
id: p.id,
type: "chunk",
position: { x: p.x, y: p.y },
draggable: false,
data: { ...p, color: colorOf(p.source_id), selected: false },
}));
const es: Edge[] = [];
// Parent-group "constellation": link a parent's children to the group's first child so you
// can see which small chunks belong to the same parent window (parent_child mode only).
const byParent = new Map();
for (const p of res.points) {
if (!p.parent_id) continue;
let arr = byParent.get(p.parent_id);
if (!arr) { arr = []; byParent.set(p.parent_id, arr); }
arr.push(p);
}
for (const [pid, kids] of byParent) {
if (kids.length < 2) continue;
const hub = kids[0].id;
for (const k of kids.slice(1)) {
es.push({ id: `pc-${pid}-${k.id}`, source: hub, target: k.id, sourceHandle: "s", targetHandle: "t", selectable: false, style: { stroke: "var(--line)", strokeWidth: 1, opacity: 0.5 } });
}
}
// Query -> retrieved links (only when a query overlay is present).
if (res.query_point) {
ns.push({ id: "__query__", type: "query", position: { x: res.query_point[0], y: res.query_point[1] }, draggable: false, data: { label: `query: ${res.query}` } });
for (const p of res.points) if (p.retrieved) {
es.push({ id: `q-${p.id}`, source: "__query__", target: p.id, sourceHandle: "s", targetHandle: "t", animated: true, selectable: false, style: { stroke: RETRIEVED, strokeWidth: 1.5 } });
}
}
setNodes(ns);
setEdges(es);
// Frame the new layout after React Flow measures the nodes.
setTimeout(() => fitView({ padding: 0.15, duration: 250 }).catch?.(() => {}), 60);
}, [res, colorOf, setNodes, setEdges, fitView]);
// Selection just flips the highlight ring on the affected dots. It must NOT re-run the builder
// above (which re-fits the view and was zooming the map out on every click) — patch the
// `selected` flag in place, leaving unchanged nodes untouched so React Flow does minimal work.
useEffect(() => {
setNodes((nds) => nds.map((n) => {
if (n.type !== "chunk") return n;
const sel = n.id === selId;
return (n.data as any).selected === sel ? n : { ...n, data: { ...n.data, selected: sel } };
}));
}, [selId, setNodes]);
// Pull the FULL chunk text on demand when a dot is selected. The panel shows the short preview
// from the map payload instantly, then swaps in the full text once it arrives (or keeps the
// preview if the fetch fails). `cancelled` guards against a slow response for a stale selection.
useEffect(() => {
if (!selId || !project?.id) { setDetail(null); setDetailErr(false); setDetailBusy(false); return; }
let cancelled = false;
setDetail(null); setDetailErr(false); setDetailBusy(true);
api.chunkDetail(project.id, selId)
.then((d) => { if (!cancelled) setDetail(d); })
.catch(() => { if (!cancelled) setDetailErr(true); })
.finally(() => { if (!cancelled) setDetailBusy(false); });
return () => { cancelled = true; };
}, [selId, project?.id]);
const selected = res?.points.find((p) => p.id === selId) || null;
const empty = res && res.points.length === 0;
return (