Files
EvoScientist-WebUI/src/app/components/ObservationGraph.tsx
T
m4 14b246f4e7
CI / Format, lint & build (push) Has been cancelled
fix: stabilize chat stream updates
2026-07-10 16:26:46 +08:00

941 lines
29 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
initPositions,
nodeColor,
nodeRadius,
relationColor,
relationLabel,
tickSimulation,
type NodePos,
type ObsEdge,
type ObsGraphData,
type ObsNode,
} from "@/lib/observationGraph";
import { X, ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
import { MarkdownContent } from "@/app/components/MarkdownContent";
// ---------------------------------------------------------------------------
// Force simulation hook
// ---------------------------------------------------------------------------
const MAX_TICKS = 600;
const ENERGY_THRESHOLD = 0.3;
const GRAPH_BUTTON_CLASS =
"rounded border border-border bg-background p-1.5 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
const ALL_RELATIONS = ["complements", "contradicts", "supersedes"] as const;
type RelationType = (typeof ALL_RELATIONS)[number];
function stripFrontMatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function useForceSimulation(
nodes: ObsNode[],
edges: ObsEdge[],
w: number,
h: number
) {
const [positions, setPositions] = useState<NodePos[]>([]);
const posRef = useRef<NodePos[]>([]);
const rafRef = useRef<number | null>(null);
const tickRef = useRef(0);
const wRef = useRef(w);
const hRef = useRef(h);
useEffect(() => {
wRef.current = w;
hRef.current = h;
}, [w, h]);
// Re-initialise only when the graph data changes — NOT on resize, so opening
// or dragging the detail panel never restarts the layout.
useEffect(() => {
if (nodes.length === 0 || wRef.current === 0 || hRef.current === 0) {
setPositions([]);
posRef.current = [];
return;
}
const init = initPositions(nodes, wRef.current, hRef.current);
posRef.current = init;
setPositions(init);
tickRef.current = 0;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
let frame = 0;
const animate = () => {
const { positions: next, energy } = tickSimulation(
posRef.current,
edges,
wRef.current,
hRef.current
);
posRef.current = next;
tickRef.current++;
frame++;
if (frame % 2 === 0) {
setPositions([...next]);
}
if (tickRef.current < MAX_TICKS && energy > ENERGY_THRESHOLD) {
rafRef.current = requestAnimationFrame(animate);
} else {
setPositions([...posRef.current]);
}
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [nodes, edges]);
return positions;
}
// ---------------------------------------------------------------------------
// Detail panel
// ---------------------------------------------------------------------------
interface DetailPanelProps {
node: ObsNode;
edges: ObsEdge[];
nodeMap: Map<string, ObsNode>;
onClose: () => void;
onNavigate: (id: string) => void;
}
function NodeDetailPanel({
node,
edges,
nodeMap,
onClose,
onNavigate,
}: DetailPanelProps) {
const [content, setContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(true);
const [contentError, setContentError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoadingContent(true);
setContent(null);
setContentError(null);
fetch(`/api/memory?path=${encodeURIComponent(node.path)}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (res) => {
const data = (await res.json().catch(() => ({}))) as {
content?: string;
error?: string;
};
if (!res.ok) {
throw new Error(data.error || "Failed to load observation.");
}
setContent(data.content ?? null);
})
.catch((error) => {
if (controller.signal.aborted) return;
setContent(null);
setContentError(
error instanceof Error ? error.message : "Failed to load observation."
);
})
.finally(() => {
if (!controller.signal.aborted) setLoadingContent(false);
});
return () => controller.abort();
}, [node.path]);
const relatedEdges = (() => {
const seen = new Set<string>();
return edges.filter((e) => {
if (e.source !== node.id && e.target !== node.id) return false;
const otherId = e.source === node.id ? e.target : e.source;
const key = `${otherId}::${e.relation}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
})();
const displayContent = content ? stripFrontMatter(content) : "";
const formatDate = (iso: string) => {
try {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
};
return (
<div className="flex h-full flex-col overflow-hidden border-l border-border bg-background shadow-xl md:shadow-none">
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{node.memory_type} · {node.scope}
</p>
<h3 className="mt-0.5 line-clamp-3 text-sm font-semibold leading-snug text-foreground">
{node.summary}
</h3>
{node.created_at && (
<p className="mt-1 text-xs text-muted-foreground">
{formatDate(node.created_at)}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="mt-0.5 inline-flex size-8 flex-shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close observation details"
title="Close observation details"
>
<X
className="h-4 w-4"
aria-hidden="true"
/>
</button>
</div>
<div
className="flex-1 overflow-y-auto"
aria-live={loadingContent ? "polite" : "off"}
>
{/* Relations */}
{relatedEdges.length > 0 && (
<div className="border-b border-border px-4 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Relations
</p>
<ul className="space-y-1.5">
{relatedEdges.map((e, idx) => {
const otherId = e.source === node.id ? e.target : e.source;
const other = nodeMap.get(otherId);
return (
<li
key={idx}
className="flex items-start gap-2"
>
<span
className="mt-0.5 flex-shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: relationColor(e.relation) }}
>
{relationLabel(e.relation)}
</span>
{other ? (
<button
type="button"
onClick={() => onNavigate(otherId)}
className="min-w-0 rounded-sm text-left text-xs text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{other.summary}
</button>
) : (
<span className="text-xs text-muted-foreground">
{otherId}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
{/* Content */}
<div className="px-4 py-3">
{loadingContent ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : contentError ? (
<p
role="alert"
className="text-xs text-destructive"
>
{contentError}
</p>
) : displayContent ? (
<MarkdownContent
content={displayContent}
className="text-xs leading-relaxed [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-sm [&_p]:mb-3"
/>
) : (
<p className="text-xs text-muted-foreground">
This observation has no body content.
</p>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SVG graph
// ---------------------------------------------------------------------------
interface GraphCanvasProps {
data: ObsGraphData;
selectedId: string | null;
onSelect: (id: string | null) => void;
w: number;
h: number;
}
function GraphCanvas({ data, selectedId, onSelect, w, h }: GraphCanvasProps) {
const physicsEdges = useMemo(() => {
const seen = new Set<string>();
return data.edges.filter((e) => {
const key = [e.source, e.target].sort().join("::");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [data.edges]);
const positions = useForceSimulation(data.nodes, physicsEdges, w, h);
// Zoom / pan state.
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const [scale, setScale] = useState(1);
const svgRef = useRef<SVGSVGElement>(null);
const dragRef = useRef<{
startX: number;
startY: number;
tx: number;
ty: number;
} | null>(null);
const [activeFilters, setActiveFilters] = useState<Set<string>>(
new Set(ALL_RELATIONS)
);
const [activeTypes, setActiveTypes] = useState<Set<string>>(
new Set(["semantic", "procedural"])
);
const posMap = useMemo(
() => new Map(positions.map((p) => [p.id, p])),
[positions]
);
const nodeMap = useMemo(
() => new Map(data.nodes.map((n) => [n.id, n])),
[data.nodes]
);
const viewRef = useRef({ tx, ty, scale });
viewRef.current = { tx, ty, scale };
const zoomAt = useCallback((px: number, py: number, factor: number) => {
const { tx, ty, scale } = viewRef.current;
const newScale = Math.max(0.2, Math.min(5, scale * factor));
const k = newScale / scale;
setScale(newScale);
setTx(px - k * (px - tx));
setTy(py - k * (py - ty));
}, []);
const handleWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
zoomAt(
e.clientX - rect.left,
e.clientY - rect.top,
e.deltaY < 0 ? 1.1 : 0.9
);
},
[zoomAt]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (
e.target === e.currentTarget ||
(e.target as Element).tagName === "svg"
) {
dragRef.current = { startX: e.clientX, startY: e.clientY, tx, ty };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
}
},
[tx, ty]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!dragRef.current) return;
setTx(dragRef.current.tx + e.clientX - dragRef.current.startX);
setTy(dragRef.current.ty + e.clientY - dragRef.current.startY);
},
[]
);
const handlePointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const resetView = () => {
setTx(0);
setTy(0);
setScale(1);
};
const toggleFilter = (rel: string) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(rel)) {
if (next.size === 1) return prev;
next.delete(rel);
} else {
next.add(rel);
}
return next;
});
};
const toggleType = (type: string) => {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
if (next.size === 1) return prev;
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
if (w === 0 || h === 0) return null;
const typeOf = (memory_type: string) =>
memory_type === "procedural" ? "procedural" : "semantic";
const visibleNodeIds = new Set(
data.nodes
.filter((n) => activeTypes.has(typeOf(n.memory_type)))
.map((n) => n.id)
);
const visibleEdges = data.edges.filter(
(e) =>
visibleNodeIds.has(e.source) &&
visibleNodeIds.has(e.target) &&
(activeFilters.has(e.relation) ||
!ALL_RELATIONS.includes(e.relation as RelationType))
);
const relations = ["complements", "contradicts", "supersedes", "default"];
return (
<>
{/* Toolbar */}
<div className="absolute left-3 top-3 z-10 flex gap-1">
<button
type="button"
onClick={() => zoomAt(w / 2, h / 2, 1.25)}
className={GRAPH_BUTTON_CLASS}
aria-label="Zoom in observation graph"
title="Zoom in"
>
<ZoomIn
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
onClick={() => zoomAt(w / 2, h / 2, 0.8)}
className={GRAPH_BUTTON_CLASS}
aria-label="Zoom out observation graph"
title="Zoom out"
>
<ZoomOut
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
onClick={resetView}
className={GRAPH_BUTTON_CLASS}
aria-label="Reset observation graph view"
title="Reset view"
>
<Maximize2
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
</div>
<div className="absolute right-3 top-3 z-10 flex max-w-[calc(100%-1.5rem)] select-none flex-col gap-1 rounded border border-border bg-background/90 px-2.5 py-2 text-[11px] shadow-sm backdrop-blur-sm">
{(["complements", "contradicts", "supersedes"] as const).map((r) => {
const active = activeFilters.has(r);
return (
<button
key={r}
type="button"
onClick={() => toggleFilter(r)}
aria-pressed={active}
title={`${active ? "Hide" : "Show"} ${r} edges`}
className={`flex items-center gap-1.5 text-left transition-opacity ${
active ? "opacity-100" : "opacity-35"
}`}
>
<span
className="inline-block h-2 w-6 flex-shrink-0 rounded-sm"
style={{ background: relationColor(r) }}
/>
<span className="capitalize text-muted-foreground">
{relationLabel(r)}
</span>
</button>
);
})}
<div className="mt-1 flex flex-col gap-0.5 border-t border-border pt-1">
{(
[
["semantic", "Semantic"],
["procedural", "Procedural"],
] as const
).map(([type, label]) => {
const active = activeTypes.has(type);
return (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
aria-pressed={active}
title={`${
active ? "Hide" : "Show"
} ${label.toLowerCase()} nodes`}
className={`flex items-center gap-1.5 text-left transition-opacity ${
active ? "opacity-100" : "opacity-35"
}`}
>
<span
className="inline-block h-3 w-3 flex-shrink-0 rounded-full"
style={{ background: nodeColor(type) }}
/>
<span className="text-muted-foreground">{label}</span>
</button>
);
})}
</div>
</div>
<svg
ref={svgRef}
width={w}
height={h}
className="absolute inset-0 cursor-grab active:cursor-grabbing"
style={{ userSelect: "none" }}
role="img"
aria-label="Observation relationship graph"
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => {
if (
e.target === e.currentTarget ||
(e.target as Element).tagName === "svg"
) {
onSelect(null);
}
}}
>
<defs>
{relations.map((rel) => (
<marker
key={rel}
id={`arrow-${rel}`}
viewBox="0 0 10 10"
markerWidth="7"
markerHeight="7"
refX="10"
refY="5"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path
d="M0,1.5 L10,5 L0,8.5 z"
fill={relationColor(rel)}
/>
</marker>
))}
</defs>
<g transform={`translate(${tx},${ty}) scale(${scale})`}>
{/* Edges */}
{visibleEdges.map((edge, idx) => {
const s = posMap.get(edge.source);
const t = posMap.get(edge.target);
if (!s || !t) return null;
const sNode = nodeMap.get(edge.source);
const tNode = nodeMap.get(edge.target);
const sr = sNode ? nodeRadius(sNode.degree) : 9;
const tr = tNode ? nodeRadius(tNode.degree) : 9;
// Offset endpoints to the circle edge so arrow doesn't overlap.
const dx = t.x - s.x;
const dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const ux = dx / dist;
const uy = dy / dist;
const x1 = s.x + ux * sr;
const y1 = s.y + uy * sr;
const x2 = t.x - ux * (tr + 2);
const y2 = t.y - uy * (tr + 2);
const rel =
edge.relation in { complements: 1, contradicts: 1, supersedes: 1 }
? edge.relation
: "default";
const color = relationColor(edge.relation);
const gradId = `edge-grad-${idx}`;
// complements is a mutual (undirected) relation — no single
// arrow direction, so fade both ends symmetrically and drop the
// arrowhead. Directional relations fade only into the arrow.
const symmetric = edge.relation === "complements";
const lineLen = Math.max(dist - sr - (tr + 2), 1);
const fadePct = Math.min(45, (7 / lineLen) * 100);
return (
<g key={idx}>
<linearGradient
id={gradId}
gradientUnits="userSpaceOnUse"
x1={x1}
y1={y1}
x2={x2}
y2={y2}
>
{symmetric ? (
<>
<stop
offset="0%"
stopColor={color}
stopOpacity={0}
/>
<stop
offset={`${fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset={`${100 - fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</>
) : (
<>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset={`${100 - fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</>
)}
</linearGradient>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={`url(#${gradId})`}
strokeWidth={1.5}
strokeDasharray={
edge.relation === "contradicts" ? "5,3" : undefined
}
markerEnd={symmetric ? undefined : `url(#arrow-${rel})`}
/>
</g>
);
})}
{/* Nodes */}
{data.nodes.map((node) => {
const pos = posMap.get(node.id);
if (!pos) return null;
if (!visibleNodeIds.has(node.id)) return null;
const r = nodeRadius(node.degree);
const isSelected = node.id === selectedId;
const label =
node.summary.length > 28
? node.summary.slice(0, 26) + "…"
: node.summary;
return (
<g
key={node.id}
role="button"
tabIndex={0}
aria-label={`Select observation: ${node.summary}`}
className="outline-none [&:focus-visible]:outline-none [&:focus]:outline-none"
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onSelect(node.id === selectedId ? null : node.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node.id === selectedId ? null : node.id);
}
}}
>
{/* Selection ring — brand accent with a soft halo */}
{isSelected && (
<>
<circle
cx={pos.x}
cy={pos.y}
r={r + 7}
fill="none"
style={{ stroke: "var(--brand)" }}
strokeWidth={2}
opacity={0.3}
/>
<circle
cx={pos.x}
cy={pos.y}
r={r + 4}
fill="none"
style={{ stroke: "var(--brand)" }}
strokeWidth={2.5}
/>
</>
)}
<circle
cx={pos.x}
cy={pos.y}
r={r}
fill={nodeColor(node.memory_type)}
stroke="#fff"
strokeWidth={1.5}
opacity={0.9}
/>
<text
x={pos.x}
y={pos.y + r + 12}
textAnchor="middle"
fontSize={10}
fill="currentColor"
className="select-none fill-foreground/70"
>
{label}
</text>
<title>{node.summary}</title>
</g>
);
})}
</g>
</svg>
</>
);
}
// ---------------------------------------------------------------------------
// Empty / loading / error states
// ---------------------------------------------------------------------------
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{message}
</div>
);
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
interface ObservationGraphProps {
data: ObsGraphData | null;
loading: boolean;
error: string | null;
highlightNodeId?: string | null;
}
export function ObservationGraph({
data,
loading,
error,
highlightNodeId,
}: ObservationGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDesktop, setIsDesktop] = useState(false);
const [detailWidth, setDetailWidth] = useState(360);
const resizeRef = useRef<{ startX: number; startW: number } | null>(null);
useEffect(() => {
if (highlightNodeId != null) {
setSelectedId(highlightNodeId);
}
}, [highlightNodeId]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const update = () =>
setIsDesktop((current) =>
current === mq.matches ? current : mq.matches
);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const onResizeDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
resizeRef.current = { startX: e.clientX, startW: detailWidth };
e.currentTarget.setPointerCapture(e.pointerId);
},
[detailWidth]
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!resizeRef.current) return;
const dx = e.clientX - resizeRef.current.startX;
const max =
typeof window !== "undefined"
? Math.max(640, window.innerWidth * 0.6)
: 640;
setDetailWidth(Math.min(max, Math.max(280, resizeRef.current.startW - dx)));
}, []);
const onResizeUp = useCallback(() => {
resizeRef.current = null;
}, []);
// Measure container.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const next = {
w: entry.contentRect.width,
h: entry.contentRect.height,
};
setDims((current) => {
if (current.w === next.w && current.h === next.h) return current;
return next;
});
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const nodeMap = new Map((data?.nodes ?? []).map((n) => [n.id, n]));
const selectedNode = selectedId ? nodeMap.get(selectedId) ?? null : null;
const showDetail = selectedNode !== null;
return (
<div className="flex h-full min-h-0 w-full flex-col">
{/* Toolbar — no "Observations" title; the parent tab already says that */}
<div className="flex items-center border-b border-border px-4 py-2">
<span className="text-xs text-muted-foreground">
{data
? `${data.nodes.length} nodes · ${data.edges.length} edges`
: loading
? "Loading…"
: ""}
</span>
</div>
{/* Body */}
<div className="relative flex min-h-0 flex-1">
{/* Graph area — always the first child so it never remounts */}
<div
ref={containerRef}
className="relative min-w-0 flex-1 overflow-hidden bg-muted/20"
>
{loading && <EmptyState message="Loading observations…" />}
{!loading && error && <EmptyState message={`Error: ${error}`} />}
{!loading && !error && data && data.nodes.length === 0 && (
<EmptyState message="No observations yet." />
)}
{!loading &&
!error &&
data &&
data.nodes.length > 0 &&
dims.w > 0 && (
<GraphCanvas
data={data}
selectedId={selectedId}
onSelect={setSelectedId}
w={dims.w}
h={dims.h}
/>
)}
</div>
{/* Detail panel — resizable side panel on desktop, overlay on mobile */}
{showDetail && isDesktop && (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onResizeDown}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
className="hover:bg-[var(--brand)]/40 w-1 flex-shrink-0 cursor-col-resize bg-border transition-colors"
/>
)}
{showDetail && (
<div
className={
isDesktop
? "flex-shrink-0 overflow-hidden"
: "absolute inset-0 z-20 overflow-hidden"
}
style={isDesktop ? { width: detailWidth } : undefined}
>
<NodeDetailPanel
node={selectedNode!}
edges={data?.edges ?? []}
nodeMap={nodeMap}
onClose={() => setSelectedId(null)}
onNavigate={(id) => setSelectedId(id)}
/>
</div>
)}
</div>
</div>
);
}