"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 (
{/* controls */}
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load(q)} /> {folders.length > 0 && ( )} {res?.query && }
setMode(v as any)} /> Dots are chunks placed by semantic similarity (PCA), colored by source. Overlay a query to mark retrieved chunks (◆ = query).
{/* legend + truncation note */} {res && res.sources.length > 0 && (
{res.sources.map((s, i) => ( {s.name} ))} {res.truncated ? `showing ${res.points.length} of ${res.total} chunks` : `${res.total} chunks`}
)} {err &&
⚠ {err}
} {/* canvas + detail panel */}
{empty ? (
No chunks yet. Add sources in the Files tab, then map them here.
) : ( setSelId(n.id === "__query__" ? null : n.id)} onPaneClick={() => setSelId(null)} nodesDraggable={false} nodesConnectable={false} minZoom={0.15} fitView proOptions={{ hideAttribution: true }} > (n.data as any)?.color || RETRIEVED} /> )} {selected && (
Chunk
{res?.sources.find((s) => s.id === selected.source_id)?.name || selected.source_id || "-"} {selected.chunk_idx != null && #{selected.chunk_idx}} {selected.retrieved && rank {selected.retrieved}} {selected.parent_id && parent}
{detail && detail.id === selId ? detail.text : selected.preview}
{detailBusy &&
Loading full chunk…
} {detailErr &&
Showing preview — full text unavailable.
}
)}
); }