ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
294 lines
15 KiB
TypeScript
294 lines
15 KiB
TypeScript
"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 (
|
|
<div style={{ position: "relative" }} title={d.preview}>
|
|
{/* Hidden handles so the query->hit / parent-group edges have anchor points. */}
|
|
<Handle type="target" id="t" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
|
<Handle type="source" id="s" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
|
<div style={{ width: size, height: size, borderRadius: "50%", background: d.color, border: "1.5px solid var(--bg-1)", boxShadow: ring, cursor: "pointer" }} />
|
|
{d.retrieved && (
|
|
<span className="mono" style={{ position: "absolute", top: -9, right: -9, fontSize: 10, fontWeight: 700, color: "#1a1400", background: RETRIEVED, borderRadius: 8, minWidth: 15, height: 15, lineHeight: "15px", textAlign: "center", padding: "0 3px" }}>{d.retrieved}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QueryMarker({ data }: NodeProps) {
|
|
return (
|
|
<div style={{ position: "relative" }} title={(data as any).label}>
|
|
<Handle type="source" id="s" position={Position.Top} style={HIDDEN_HANDLE} isConnectable={false} />
|
|
<div style={{ width: 18, height: 18, background: RETRIEVED, transform: "rotate(45deg)", border: "2px solid var(--bg-1)", boxShadow: `0 0 10px ${RETRIEVED}` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const NODE_TYPES = { chunk: ChunkDot, query: QueryMarker };
|
|
|
|
// --- main ---
|
|
|
|
export function ChunkMap({ project }: { project: any }) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<ChunkMapInner project={project} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
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<number>(400);
|
|
const [folders, setFolders] = useState<string[]>([]);
|
|
const [folder, setFolder] = useState("");
|
|
const [res, setRes] = useState<ChunkMapResult | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [selId, setSelId] = useState<string | null>(null);
|
|
// Full text of the selected chunk, fetched on demand (the map payload carries only a preview).
|
|
const [detail, setDetail] = useState<ChunkDetail | null>(null);
|
|
const [detailBusy, setDetailBusy] = useState(false);
|
|
const [detailErr, setDetailErr] = useState(false);
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
const { fitView } = useReactFlow();
|
|
|
|
// Color per source id (stable within a load, keyed off the legend order).
|
|
const colorOf = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
(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<string, ChunkPoint[]>();
|
|
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 (
|
|
<div className="col" style={{ gap: 10 }}>
|
|
{/* controls */}
|
|
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
|
<input className="input" style={{ flex: 1, minWidth: 220 }} placeholder="Overlay a query to see what retrieval returns…" value={q}
|
|
onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load(q)} />
|
|
{folders.length > 0 && (
|
|
<select className="select" style={{ width: 150 }} value={folder} onChange={(e) => setFolder(e.target.value)}>
|
|
<option value="">All folders</option>
|
|
{folders.map((f) => <option key={f} value={f}>{f}</option>)}
|
|
</select>
|
|
)}
|
|
<button className="btn btn-primary" onClick={() => load(q)} disabled={loading}><Icon name="search" size={14} />{loading ? "Mapping…" : "Map query"}</button>
|
|
{res?.query && <button className="btn btn-ghost btn-sm" onClick={() => { setQ(""); load(); }}>Clear overlay</button>}
|
|
</div>
|
|
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
|
<Segmented options={[{ value: "vector", label: "Vector" }, { value: "hybrid", label: "Hybrid" }]} value={mode} onChange={(v) => setMode(v as any)} />
|
|
<label className="row gap1" style={{ alignItems: "center", cursor: "pointer", fontSize: 13 }} title="Two-stage cross-encoder rerank for the query overlay.">
|
|
<input type="checkbox" checked={rerank} onChange={(e) => setRerank(e.target.checked)} />Rerank
|
|
</label>
|
|
<label className="row gap1" style={{ alignItems: "center", fontSize: 13 }} title="How many chunks to plot. More points give a fuller picture but take longer to project.">
|
|
Max points
|
|
<select className="select" style={{ width: 84 }} value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
|
|
{POINT_LIMITS.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
</select>
|
|
</label>
|
|
<span className="t-caption fg-2">Dots are chunks placed by semantic similarity (PCA), colored by source. Overlay a query to mark retrieved chunks (◆ = query).</span>
|
|
</div>
|
|
|
|
{/* legend + truncation note */}
|
|
{res && res.sources.length > 0 && (
|
|
<div className="row gap2" style={{ alignItems: "center", flexWrap: "wrap" }}>
|
|
{res.sources.map((s, i) => (
|
|
<span key={s.id} className="row gap1 t-caption" style={{ alignItems: "center" }}>
|
|
<span style={{ width: 10, height: 10, borderRadius: "50%", background: PALETTE[i % PALETTE.length], display: "inline-block" }} />
|
|
<span className="truncate" style={{ maxWidth: 160 }}>{s.name}</span>
|
|
</span>
|
|
))}
|
|
<span className="t-caption fg-2" style={{ marginLeft: "auto" }}>
|
|
{res.truncated ? `showing ${res.points.length} of ${res.total} chunks` : `${res.total} chunks`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{err && <div className="t-caption" style={{ color: "var(--danger, #c00)" }}>⚠ {err}</div>}
|
|
|
|
{/* canvas + detail panel */}
|
|
<div className="card" style={{ position: "relative", height: 560, overflow: "hidden", padding: 0 }}>
|
|
{empty ? (
|
|
<div className="col center" style={{ width: "100%", height: "100%", color: "var(--fg-2)", gap: 6 }}>
|
|
<Icon name="layers" size={22} />
|
|
<div>No chunks yet. Add sources in the Files tab, then map them here.</div>
|
|
</div>
|
|
) : (
|
|
<ReactFlow
|
|
style={{ width: "100%", height: "100%" }}
|
|
nodes={nodes} edges={edges} nodeTypes={NODE_TYPES}
|
|
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
|
|
onNodeClick={(_, n) => setSelId(n.id === "__query__" ? null : n.id)} onPaneClick={() => setSelId(null)}
|
|
nodesDraggable={false} nodesConnectable={false} minZoom={0.15}
|
|
fitView proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--canvas-grid)" />
|
|
<Controls showInteractive={false} />
|
|
<MiniMap pannable zoomable style={{ background: "var(--bg-1)" }} nodeColor={(n) => (n.data as any)?.color || RETRIEVED} />
|
|
</ReactFlow>
|
|
)}
|
|
|
|
{selected && (
|
|
<div className="card" style={{ position: "absolute", top: 10, right: 10, width: 300, padding: 12, boxShadow: "var(--sh-pop)", zIndex: 20 }}>
|
|
<div className="row spread" style={{ marginBottom: 6 }}>
|
|
<span className="t-micro">Chunk</span>
|
|
<button className="iconbtn" onClick={() => setSelId(null)}><Icon name="x" size={14} /></button>
|
|
</div>
|
|
<div className="row gap2 t-caption fg-2" style={{ marginBottom: 8, flexWrap: "wrap" }}>
|
|
<span className="chip">{res?.sources.find((s) => s.id === selected.source_id)?.name || selected.source_id || "-"}</span>
|
|
{selected.chunk_idx != null && <span className="chip chip-mono">#{selected.chunk_idx}</span>}
|
|
{selected.retrieved && <span className="chip chip-mono" style={{ color: RETRIEVED }}>rank {selected.retrieved}</span>}
|
|
{selected.parent_id && <span className="chip chip-mono" title={selected.parent_id}>parent</span>}
|
|
</div>
|
|
<div className="t-body-sm" style={{ maxHeight: 360, overflow: "auto", whiteSpace: "pre-wrap" }}>
|
|
{detail && detail.id === selId ? detail.text : selected.preview}
|
|
</div>
|
|
{detailBusy && <div className="t-caption fg-2" style={{ marginTop: 6 }}>Loading full chunk…</div>}
|
|
{detailErr && <div className="t-caption fg-2" style={{ marginTop: 6 }}>Showing preview — full text unavailable.</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|