Files
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
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.
2026-07-28 01:49:19 +05:30

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>
);
}