chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
@@ -0,0 +1,97 @@
"use client";
import { useCallback, useState } from "react";
import { Archive, ChevronDown, ChevronUp, X } from "lucide-react";
import { useTranslation } from "react-i18next";
const STORAGE_KEY = "dt:memory:banner-dismissed";
interface MemoryArchivedBannerProps {
latestBackup: string | null;
variant?: "full" | "compact";
}
export default function MemoryArchivedBanner({
latestBackup,
variant = "compact",
}: MemoryArchivedBannerProps) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState<string | null>(() => {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(STORAGE_KEY);
});
const [expanded, setExpanded] = useState(false);
const dismiss = useCallback(() => {
if (!latestBackup) return;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, latestBackup);
}
setDismissed(latestBackup);
}, [latestBackup]);
if (!latestBackup || dismissed === latestBackup) return null;
if (variant === "full") {
return (
<div className="relative flex items-start gap-3 rounded-2xl border border-[var(--border)] bg-[var(--muted)] px-5 py-4 pr-12 text-[13px]">
<Archive className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
<div>
<p className="font-medium text-[var(--foreground)]">
{t("Your v1 memory was archived")}
</p>
<p className="mt-0.5 text-[var(--muted-foreground)]">
{t(
"Stored at memory/backup/{{name}}. v2 starts fresh — interact with DeepTutor and click Update on each doc to build memory.",
{ name: latestBackup },
)}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("Dismiss")}
className="absolute right-2 top-2 rounded-md p-1.5 text-[var(--muted-foreground)] transition hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--muted)]/60 text-[12px]">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-4 py-2 text-left"
>
<span className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Archive className="h-3.5 w-3.5" />
{t("Your v1 memory was archived")}
</span>
{expanded ? (
<ChevronUp className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
)}
</button>
{expanded && (
<div className="relative border-t border-[var(--border)] px-4 py-3 pr-10 text-[var(--muted-foreground)]">
{t(
"Stored at memory/backup/{{name}}. v2 starts fresh — interact with DeepTutor and click Update on each doc to build memory.",
{ name: latestBackup },
)}
<button
type="button"
onClick={dismiss}
aria-label={t("Dismiss")}
className="absolute right-2 top-2 rounded-md p-1 text-[var(--muted-foreground)] transition hover:bg-[var(--background)] hover:text-[var(--foreground)]"
>
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}
+989
View File
@@ -0,0 +1,989 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type Dispatch,
type SetStateAction,
type WheelEvent as ReactWheelEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ArrowLeft,
Eye,
EyeOff,
Loader2,
Maximize2,
RefreshCw,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
buildGraph,
DEFAULT_LAYOUT,
fetchMemorySnapshot,
L3_LABEL,
SURFACE_LABEL,
type ClusterMeta,
type GraphEdge,
type GraphNode,
type Layer,
type MemoryGraph,
} from "@/lib/memory-graph";
interface ViewState {
scale: number;
tx: number;
ty: number;
}
interface HoverState {
node: GraphNode;
containerLeft: number;
containerTop: number;
}
const INITIAL_VIEW: ViewState = { scale: 1, tx: 0, ty: 0 };
// One canonical hue per layer; alpha is per-state (idle/hover/dim).
// Drawn from DeepTutor's primary palette so dark mode stays warm too.
const LAYER_COLOR: Record<Layer, string> = {
L3: "var(--primary)",
L2: "color-mix(in srgb, var(--primary) 78%, var(--foreground) 22%)",
L1: "color-mix(in srgb, var(--primary) 38%, var(--foreground) 62%)",
};
const RING_LINE: Record<Layer, string> = {
L3: "color-mix(in srgb, var(--primary) 22%, transparent)",
L2: "color-mix(in srgb, var(--primary) 14%, transparent)",
L1: "color-mix(in srgb, var(--foreground) 10%, transparent)",
};
export default function MemoryGraph() {
const { t } = useTranslation();
const [graph, setGraph] = useState<MemoryGraph | null>(null);
const [loading, setLoading] = useState(true);
const [layerOn, setLayerOn] = useState<Record<Layer, boolean>>({
L1: true,
L2: true,
L3: true,
});
const [hover, setHover] = useState<HoverState | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [view, setView] = useState<ViewState>(INITIAL_VIEW);
const containerRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
origTx: number;
origTy: number;
} | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const snap = await fetchMemorySnapshot();
setGraph(buildGraph(snap));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// ── Derived: active node id used by hover *or* explicit selection.
const activeId = selected ?? hover?.node.id ?? null;
const highlight = useMemo(() => {
if (!graph || !activeId) return null;
const neighbours = new Set<string>([activeId]);
const queue = [activeId];
// BFS limited to 2 hops so we get e.g. L3 → L2 → L1 chains.
for (let depth = 0; depth < 2 && queue.length; depth++) {
const next: string[] = [];
for (const id of queue) {
const adj = graph.adjacency.get(id) ?? [];
for (const n of adj) {
if (!neighbours.has(n)) {
neighbours.add(n);
next.push(n);
}
}
}
queue.length = 0;
queue.push(...next);
}
return neighbours;
}, [graph, activeId]);
// ── Pan / zoom handlers.
const onWheel = useCallback((e: ReactWheelEvent<HTMLDivElement>) => {
e.preventDefault();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setView((v) => {
const factor = Math.exp(-e.deltaY * 0.001);
const next = Math.min(4, Math.max(0.35, v.scale * factor));
// Zoom centered on cursor: keep the world point under the cursor stationary.
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const k = next / v.scale;
const tx = px - k * (px - v.tx);
const ty = py - k * (py - v.ty);
return { scale: next, tx, ty };
});
}, []);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Only start a drag on background clicks — let node clicks bubble.
if ((e.target as HTMLElement).closest("[data-node]")) return;
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
origTx: view.tx,
origTy: view.ty,
};
setSelected(null);
},
[view],
);
const onPointerMove = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
setView((v) => ({
...v,
tx: d.origTx + (e.clientX - d.startX),
ty: d.origTy + (e.clientY - d.startY),
}));
}, []);
const endDrag = useCallback(() => {
dragRef.current = null;
}, []);
const showNodeHover = useCallback((node: GraphNode) => {
const rect = containerRef.current?.getBoundingClientRect();
setHover({
node,
containerLeft: rect?.left ?? 0,
containerTop: rect?.top ?? 0,
});
}, []);
// ── Center / fit the graph to the container on first paint.
useEffect(() => {
if (!graph) return;
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const target = DEFAULT_LAYOUT.width;
const scale = Math.min(rect.width / target, rect.height / target) * 0.95;
setView({
scale,
tx: (rect.width - target * scale) / 2,
ty: (rect.height - target * scale) / 2,
});
}, [graph]);
const fit = useCallback(() => {
if (!graph) return;
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const target = DEFAULT_LAYOUT.width;
const scale = Math.min(rect.width / target, rect.height / target) * 0.95;
setView({
scale,
tx: (rect.width - target * scale) / 2,
ty: (rect.height - target * scale) / 2,
});
}, [graph]);
const zoomBy = useCallback((factor: number) => {
const el = containerRef.current;
const rect = el?.getBoundingClientRect();
if (!rect) return;
setView((v) => {
const next = Math.min(4, Math.max(0.35, v.scale * factor));
const px = rect.width / 2;
const py = rect.height / 2;
const k = next / v.scale;
return {
scale: next,
tx: px - k * (px - v.tx),
ty: py - k * (py - v.ty),
};
});
}, []);
return (
<div className="flex h-full min-h-0 flex-col">
<Header onRefresh={() => void load()} loading={loading} />
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="absolute inset-0 cursor-grab touch-none select-none active:cursor-grabbing"
style={{
background:
"radial-gradient(ellipse at center, color-mix(in srgb, var(--primary) 7%, var(--background)) 0%, var(--background) 65%)",
}}
>
{graph && (
<GraphView
graph={graph}
view={view}
layerOn={layerOn}
hover={hover}
showNodeHover={showNodeHover}
setHover={setHover}
selected={selected}
setSelected={setSelected}
highlight={highlight}
/>
)}
{loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--card)]/80 px-3 py-1.5 text-[12px] text-[var(--muted-foreground)] shadow-sm backdrop-blur">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("Composing memory graph…")}
</div>
</div>
)}
{graph && !loading && (
<Controls
view={view}
layerOn={layerOn}
setLayerOn={setLayerOn}
zoomBy={zoomBy}
fit={fit}
/>
)}
{hover && <HoverCard hover={hover} view={view} />}
</div>
{graph && !loading && <Legend graph={graph} />}
</div>
</div>
);
}
// ───────────────────────────────────────────────────────────── Header
function Header({
onRefresh,
loading,
}: {
onRefresh: () => void;
loading: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--background)]/80 px-6 py-3 backdrop-blur md:px-10">
<div className="flex items-center gap-3">
<Link
href="/memory"
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("Memory")}
</Link>
<div className="flex flex-col">
<h1 className="font-serif text-[16px] font-semibold tracking-tight text-[var(--foreground)]">
{t("Memory graph")}
</h1>
<p className="text-[11.5px] text-[var(--muted-foreground)]">
{t(
"L3 synthesis at the centre, L2 facts in the middle ring, L1 traces on the outside.",
)}
</p>
</div>
</div>
<button
type="button"
onClick={onRefresh}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] disabled:opacity-50"
disabled={loading}
>
<RefreshCw
className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"}
/>
{t("Refresh")}
</button>
</div>
);
}
// ───────────────────────────────────────────────────────── GraphView
interface GraphViewProps {
graph: MemoryGraph;
view: ViewState;
layerOn: Record<Layer, boolean>;
hover: HoverState | null;
showNodeHover: (node: GraphNode) => void;
setHover: Dispatch<SetStateAction<HoverState | null>>;
selected: string | null;
setSelected: Dispatch<SetStateAction<string | null>>;
highlight: Set<string> | null;
}
function GraphView({
graph,
view,
layerOn,
hover,
showNodeHover,
setHover,
selected,
setSelected,
highlight,
}: GraphViewProps) {
const { t } = useTranslation();
// ── Cluster halos: large blurred translucent ellipses behind each
// slice so users see the cluster outlines before they read labels.
const haloPaths = useMemo(() => {
return graph.clusters.map((c) => buildClusterArc(c));
}, [graph.clusters]);
const visibleEdges = useMemo(() => {
return graph.edges.filter((e) => {
const sLayer = e.source.startsWith("L1")
? "L1"
: e.source.startsWith("L2")
? "L2"
: "L3";
const tLayer = e.target.startsWith("L1")
? "L1"
: e.target.startsWith("L2")
? "L2"
: "L3";
return layerOn[sLayer as Layer] && layerOn[tLayer as Layer];
});
}, [graph.edges, layerOn]);
// Pre-index nodes by id for fast endpoint lookup when drawing edges.
const nodeIdx = useMemo(() => {
const m = new Map<string, GraphNode>();
for (const n of graph.nodes) m.set(n.id, n);
return m;
}, [graph.nodes]);
return (
<svg
width="100%"
height="100%"
style={{ position: "absolute", inset: 0 }}
aria-label={t("Memory graph")}
>
<defs>
<radialGradient id="halo-l3" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.20" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.02" />
</radialGradient>
<radialGradient id="halo-l2" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--primary)" stopOpacity="0.12" />
<stop offset="100%" stopColor="var(--primary)" stopOpacity="0.02" />
</radialGradient>
<radialGradient id="halo-l1" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--foreground)" stopOpacity="0.08" />
<stop
offset="100%"
stopColor="var(--foreground)"
stopOpacity="0.01"
/>
</radialGradient>
</defs>
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.scale})`}>
{/* Concentric ring guides (faint). */}
<g pointerEvents="none">
{(["L1", "L2", "L3"] as const).map((layer) => {
if (!layerOn[layer]) return null;
const opt = DEFAULT_LAYOUT;
const inner =
layer === "L1"
? opt.l1InnerRadius
: layer === "L2"
? opt.l2InnerRadius
: opt.l3InnerRadius;
const outer =
layer === "L1"
? opt.l1OuterRadius
: layer === "L2"
? opt.l2OuterRadius
: opt.l3OuterRadius;
const cx = opt.width / 2;
const cy = opt.height / 2;
return (
<g key={`ring-${layer}`}>
<circle
cx={cx}
cy={cy}
r={inner}
fill="none"
stroke={RING_LINE[layer]}
strokeWidth={0.6}
strokeDasharray="2 3"
/>
<circle
cx={cx}
cy={cy}
r={outer}
fill="none"
stroke={RING_LINE[layer]}
strokeWidth={0.6}
strokeDasharray="2 3"
/>
</g>
);
})}
</g>
{/* Cluster halos. */}
<g pointerEvents="none">
{graph.clusters.map((c, i) => {
if (!layerOn[c.layer]) return null;
const fill = `url(#halo-${c.layer.toLowerCase()})`;
return (
<path
key={c.id}
d={haloPaths[i]}
fill={fill}
stroke="color-mix(in srgb, var(--primary) 8%, transparent)"
strokeWidth={0.6}
/>
);
})}
</g>
{/* Cluster labels. L1 labels orbit *outside* the outer ring,
L2 labels sit just outside the L2 ring, L3 labels are
rendered as a single core badge (handled below). */}
<g pointerEvents="none">
{graph.clusters.map((c) => {
if (!layerOn[c.layer]) return null;
if (c.layer === "L3") return null;
const mid = (c.startAngle + c.endAngle) / 2;
const cx = DEFAULT_LAYOUT.width / 2;
const cy = DEFAULT_LAYOUT.height / 2;
const rOut = c.outerRadius + (c.layer === "L1" ? 30 : 18);
const x = cx + Math.cos(mid) * rOut;
const y = cy + Math.sin(mid) * rOut;
return (
<g key={`label-${c.id}`}>
<text
x={x}
y={y - 12}
fontSize={c.layer === "L2" ? 20 : 22}
fontWeight={600}
fill="var(--foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ userSelect: "none" }}
>
{t(c.label)}
</text>
<text
x={x}
y={y + 14}
fontSize={15}
fontWeight={500}
fill="var(--muted-foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ userSelect: "none" }}
>
{c.count.toLocaleString()}{" "}
{c.layer === "L2" ? t("facts") : t("traces")}
</text>
</g>
);
})}
</g>
{/* L3 core: a soft "core" plate at the centre with the three
slot labels placed at their slice mid-angles inside the
inner radius. */}
{layerOn.L3 && (
<g pointerEvents="none">
<circle
cx={DEFAULT_LAYOUT.width / 2}
cy={DEFAULT_LAYOUT.height / 2}
r={DEFAULT_LAYOUT.l3InnerRadius - 6}
fill="color-mix(in srgb, var(--primary) 12%, var(--background))"
stroke="color-mix(in srgb, var(--primary) 22%, transparent)"
strokeWidth={1}
/>
<text
x={DEFAULT_LAYOUT.width / 2}
y={DEFAULT_LAYOUT.height / 2 - 10}
fontSize={22}
fontWeight={700}
fill="var(--primary)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.08em", userSelect: "none" }}
>
L3
</text>
<text
x={DEFAULT_LAYOUT.width / 2}
y={DEFAULT_LAYOUT.height / 2 + 16}
fontSize={14}
fontWeight={500}
fill="var(--muted-foreground)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.04em", userSelect: "none" }}
>
{t("synthesis")}
</text>
{graph.clusters
.filter((c) => c.layer === "L3")
.map((c) => {
const mid = (c.startAngle + c.endAngle) / 2;
const cx = DEFAULT_LAYOUT.width / 2;
const cy = DEFAULT_LAYOUT.height / 2;
const r = c.outerRadius + 14;
const x = cx + Math.cos(mid) * r;
const y = cy + Math.sin(mid) * r;
return (
<text
key={`l3-label-${c.id}`}
x={x}
y={y}
fontSize={18}
fontWeight={600}
fill="var(--primary)"
textAnchor="middle"
dominantBaseline="central"
style={{ letterSpacing: "0.04em", userSelect: "none" }}
>
{t(c.label)}
</text>
);
})}
</g>
)}
{/* Edges. */}
<g pointerEvents="none">
{visibleEdges.map((e) => {
const s = nodeIdx.get(e.source);
const t2 = nodeIdx.get(e.target);
if (!s || !t2) return null;
const dim =
highlight !== null &&
!highlight.has(e.source) &&
!highlight.has(e.target);
const focused =
highlight !== null &&
highlight.has(e.source) &&
highlight.has(e.target);
const opacity = dim
? 0.04
: focused
? e.kind === "strong"
? 0.85
: 0.55
: e.kind === "strong"
? 0.16
: 0.08;
const stroke = focused ? "var(--primary)" : LAYER_COLOR[layerOf(e)];
return (
<EdgePath
key={`${e.source}->${e.target}`}
edge={e}
s={s}
t={t2}
opacity={opacity}
stroke={stroke}
focused={focused}
/>
);
})}
</g>
{/* Nodes. */}
<g>
{graph.nodes.map((n) => {
if (!layerOn[n.layer]) return null;
if (n.r === 0) return null; // synthetic anchor
const dim = highlight !== null && !highlight.has(n.id);
const focused = highlight !== null && highlight.has(n.id);
const isActive = n.id === (selected ?? hover?.node.id);
return (
<NodeDot
key={n.id}
node={n}
isActive={isActive}
focused={focused}
dim={dim}
onEnter={showNodeHover}
onLeave={(node) =>
setHover((cur) => (cur?.node.id === node.id ? null : cur))
}
onClick={() =>
setSelected((cur) => (cur === n.id ? null : n.id))
}
/>
);
})}
</g>
</g>
</svg>
);
}
function layerOf(e: GraphEdge): Layer {
// Pick the more synthetic layer for edge colour: L3 dominates,
// then L2.
if (e.source.startsWith("L3") || e.target.startsWith("L3")) return "L3";
if (e.source.startsWith("L2") || e.target.startsWith("L2")) return "L2";
return "L1";
}
function EdgePath({
edge,
s,
t,
opacity,
stroke,
focused,
}: {
edge: GraphEdge;
s: GraphNode;
t: GraphNode;
opacity: number;
stroke: string;
focused: boolean;
}) {
// Quadratic curve: control point pulled toward the canvas center
// gives the bundle a hub-and-spoke feel rather than a noisy mesh.
const cx = DEFAULT_LAYOUT.width / 2;
const cy = DEFAULT_LAYOUT.height / 2;
const mx = (s.x + t.x) / 2;
const my = (s.y + t.y) / 2;
const k = edge.kind === "soft" ? 0.45 : 0.25;
const cpx = mx + (cx - mx) * k;
const cpy = my + (cy - my) * k;
return (
<path
d={`M ${s.x} ${s.y} Q ${cpx} ${cpy} ${t.x} ${t.y}`}
stroke={stroke}
strokeWidth={focused ? 0.9 : edge.kind === "strong" ? 0.5 : 0.35}
strokeOpacity={opacity}
fill="none"
strokeLinecap="round"
/>
);
}
function NodeDot({
node,
isActive,
focused,
dim,
onEnter,
onLeave,
onClick,
}: {
node: GraphNode;
isActive: boolean;
focused: boolean;
dim: boolean;
onEnter: (node: GraphNode) => void;
onLeave: (node: GraphNode) => void;
onClick: () => void;
}) {
const baseR = node.r;
const r = isActive ? baseR * 2.1 : focused ? baseR * 1.35 : baseR;
const fill = LAYER_COLOR[node.layer];
const opacity = dim ? 0.18 : 1;
// Always offer at least a 10-pixel-radius invisible target so the
// mouse doesn't need pixel-perfect aim. Larger of (3×base radius,
// 10px) gives small L1 dots a generous hit halo without making
// dense clusters fight over hover.
const hitR = Math.max(baseR * 3, 10);
return (
<g data-node={node.id} style={{ cursor: "pointer" }}>
<circle
cx={node.x}
cy={node.y}
r={hitR}
fill="transparent"
pointerEvents="all"
onPointerEnter={() => onEnter(node)}
onPointerLeave={() => onLeave(node)}
onClick={onClick}
/>
{isActive && (
<circle
cx={node.x}
cy={node.y}
r={r * 2.5}
fill={fill}
opacity={0.18}
pointerEvents="none"
/>
)}
<circle
cx={node.x}
cy={node.y}
r={r}
fill={fill}
opacity={opacity}
stroke={isActive ? "var(--background)" : "transparent"}
strokeWidth={isActive ? 1.5 : 0}
pointerEvents="none"
/>
</g>
);
}
// Pie-slice "arc" path between two radii.
function buildClusterArc(c: ClusterMeta): string {
const cx = DEFAULT_LAYOUT.width / 2;
const cy = DEFAULT_LAYOUT.height / 2;
const { startAngle, endAngle, innerRadius, outerRadius } = c;
const large = endAngle - startAngle > Math.PI ? 1 : 0;
const x1 = cx + Math.cos(startAngle) * outerRadius;
const y1 = cy + Math.sin(startAngle) * outerRadius;
const x2 = cx + Math.cos(endAngle) * outerRadius;
const y2 = cy + Math.sin(endAngle) * outerRadius;
const x3 = cx + Math.cos(endAngle) * innerRadius;
const y3 = cy + Math.sin(endAngle) * innerRadius;
const x4 = cx + Math.cos(startAngle) * innerRadius;
const y4 = cy + Math.sin(startAngle) * innerRadius;
return [
`M ${x1} ${y1}`,
`A ${outerRadius} ${outerRadius} 0 ${large} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${innerRadius} ${innerRadius} 0 ${large} 0 ${x4} ${y4}`,
"Z",
].join(" ");
}
// ─────────────────────────────────────────────────────────── Controls
function Controls({
view,
layerOn,
setLayerOn,
zoomBy,
fit,
}: {
view: ViewState;
layerOn: Record<Layer, boolean>;
setLayerOn: (next: Record<Layer, boolean>) => void;
zoomBy: (factor: number) => void;
fit: () => void;
}) {
const { t } = useTranslation();
return (
<div className="pointer-events-none absolute right-4 top-4 flex flex-col items-end gap-2">
<div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)]/95 shadow-sm backdrop-blur">
<IconButton onClick={() => zoomBy(1.25)} title={t("Zoom in")}>
<ZoomIn className="h-3.5 w-3.5" />
</IconButton>
<IconButton onClick={() => zoomBy(1 / 1.25)} title={t("Zoom out")}>
<ZoomOut className="h-3.5 w-3.5" />
</IconButton>
<IconButton onClick={fit} title={t("Fit")}>
<Maximize2 className="h-3.5 w-3.5" />
</IconButton>
<span className="px-2 text-[11px] tabular-nums text-[var(--muted-foreground)]">
{(view.scale * 100).toFixed(0)}%
</span>
</div>
<div className="pointer-events-auto flex flex-col gap-1 rounded-lg border border-[var(--border)] bg-[var(--card)]/95 p-1.5 shadow-sm backdrop-blur">
{(["L3", "L2", "L1"] as const).map((layer) => (
<button
key={layer}
type="button"
onClick={() => setLayerOn({ ...layerOn, [layer]: !layerOn[layer] })}
className="group inline-flex items-center gap-2 rounded-md px-2 py-1 text-[11.5px] text-[var(--foreground)] transition hover:bg-[var(--muted)]"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[layer] }}
/>
<span className="font-medium">{layer}</span>
<span className="text-[var(--muted-foreground)]">
{layer === "L3"
? t("Synthesis")
: layer === "L2"
? t("Per-surface")
: t("Raw traces")}
</span>
{layerOn[layer] ? (
<Eye className="ml-1 h-3 w-3 text-[var(--muted-foreground)]" />
) : (
<EyeOff className="ml-1 h-3 w-3 text-[var(--muted-foreground)]/60" />
)}
</button>
))}
</div>
</div>
);
}
function IconButton({
onClick,
title,
children,
}: {
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className="grid h-8 w-8 place-items-center text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
{children}
</button>
);
}
// ──────────────────────────────────────────────────────────── Legend
function Legend({ graph }: { graph: MemoryGraph }) {
const { t } = useTranslation();
// Aggregate counts.
const byLayer = useMemo(() => {
const out: Record<Layer, number> = { L1: 0, L2: 0, L3: 0 };
for (const n of graph.nodes) {
if (n.r === 0) continue; // ignore synthetic anchors
out[n.layer] += 1;
}
return out;
}, [graph.nodes]);
return (
<div className="pointer-events-none absolute bottom-4 left-4 max-w-md rounded-lg border border-[var(--border)] bg-[var(--card)]/92 p-3 text-[11.5px] shadow-sm backdrop-blur">
<div className="mb-2 flex items-center gap-3 text-[var(--muted-foreground)]">
{(["L3", "L2", "L1"] as const).map((layer) => (
<span key={layer} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[layer] }}
/>
<span className="font-medium text-[var(--foreground)]">
{layer}
</span>
<span>{byLayer[layer]}</span>
</span>
))}
</div>
<p className="leading-relaxed text-[var(--muted-foreground)]">
{t(
"Hover a node to preview the memory. Click to lock the highlight and trace its references inward (L1 → L2 → L3) or outward.",
)}
</p>
</div>
);
}
// ─────────────────────────────────────────────────────── HoverCard
function HoverCard({ hover, view }: { hover: HoverState; view: ViewState }) {
const { t } = useTranslation();
const { node } = hover;
// Anchor the tooltip to the *node's* screen position (not the
// cursor), so we don't have to handle mousemove for every dot. The
// card is offset down-right of the node so the user can still read
// the dot underneath the cursor.
const screenX = hover.containerLeft + node.x * view.scale + view.tx;
const screenY = hover.containerTop + node.y * view.scale + view.ty;
const offset = Math.max(node.r * view.scale + 14, 18);
const style: CSSProperties = {
left: screenX + offset,
top: screenY + offset,
};
const meta = nodeMeta(node, t);
return (
<div
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-[var(--border)] bg-[var(--card)] p-3 text-[12px] shadow-lg"
style={style}
>
<div className="mb-1 flex items-center gap-2 text-[10.5px] uppercase tracking-wider text-[var(--muted-foreground)]">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: LAYER_COLOR[node.layer] }}
/>
<span>{meta.layerLabel}</span>
<span className="text-[var(--border)]">·</span>
<span>{meta.clusterLabel}</span>
{node.section && (
<>
<span className="text-[var(--border)]">·</span>
<span className="text-[var(--foreground)]/80">{node.section}</span>
</>
)}
</div>
<p className="leading-relaxed text-[var(--foreground)] line-clamp-6">
{node.preview || node.label}
</p>
<p className="mt-2 text-[10.5px] text-[var(--muted-foreground)]">
{t("Click to lock · open in workbench")}
</p>
</div>
);
}
function nodeMeta(
n: GraphNode,
t: (key: string) => string,
): { layerLabel: string; clusterLabel: string } {
const [, cluster] = n.cluster.split(":");
if (n.layer === "L3") {
return {
layerLabel: t("L3 · synthesis"),
clusterLabel: t(L3_LABEL[cluster as keyof typeof L3_LABEL] || cluster),
};
}
if (n.layer === "L2") {
return {
layerLabel: t("L2 · curated"),
clusterLabel: t(
SURFACE_LABEL[cluster as keyof typeof SURFACE_LABEL] || cluster,
),
};
}
return {
layerLabel: t("L1 · raw trace"),
clusterLabel: t(
SURFACE_LABEL[cluster as keyof typeof SURFACE_LABEL] || cluster,
),
};
}
export type { GraphNode, GraphEdge, MemoryGraph };
+265
View File
@@ -0,0 +1,265 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import {
ArrowRight,
Brain,
Layers,
Network,
RefreshCw,
Sparkles,
Workflow,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import MemoryArchivedBanner from "@/components/memory/MemoryArchivedBanner";
interface DocOverview {
layer: "L2" | "L3";
key: string;
exists: boolean;
updated_at: string | null;
entry_count: number;
backlog: number;
}
interface OverviewResponse {
docs: DocOverview[];
backups: string[];
}
interface SnapshotResponse {
surface: string;
entities: unknown[];
}
const SURFACES = [
"chat",
"notebook",
"quiz",
"kb",
"book",
"partner",
"cowriter",
] as const;
const L3_VISIBLE = ["recent", "profile", "scope"] as const;
export default function MemoryHub() {
const { t } = useTranslation();
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [l1Total, setL1Total] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const [ovRes, ...l1Counts] = await Promise.all([
apiFetch(apiUrl("/api/v1/memory/overview")).then((r) => r.json()),
...SURFACES.map((s) =>
apiFetch(apiUrl(`/api/v1/memory/snapshot/${s}`))
.then((r) => r.json())
.then((d: SnapshotResponse) => d?.entities?.length ?? 0)
.catch(() => 0),
),
]);
setOverview(ovRes as OverviewResponse);
setL1Total(l1Counts.reduce<number>((acc, n) => acc + (n as number), 0));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const l2Docs = (overview?.docs || []).filter((d) => d.layer === "L2");
const l3Docs = (overview?.docs || []).filter(
(d) => d.layer === "L3" && d.key !== "preferences",
);
const l2Total = l2Docs.reduce((acc, d) => acc + d.entry_count, 0);
const l3Total = l3Docs.reduce((acc, d) => acc + d.entry_count, 0);
const latestBackup = overview?.backups?.[overview.backups.length - 1] ?? null;
return (
<div className="space-y-10">
<header className="space-y-3">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<Brain className="h-5 w-5" />
</span>
<h1 className="font-serif text-[28px] font-semibold tracking-tight text-[var(--foreground)] md:text-[32px]">
{t("Memory")}
</h1>
</div>
<p className="max-w-2xl text-[14px] text-[var(--muted-foreground)] md:text-[15px]">
{t(
"Everything DeepTutor remembers about you, organised across three layers. Click into any layer to inspect or curate it.",
)}
</p>
<div className="flex items-center gap-3 text-[12px] text-[var(--muted-foreground)]">
<button
type="button"
onClick={() => void load()}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 transition hover:bg-[var(--muted)]"
>
<RefreshCw
className={loading ? "h-3 w-3 animate-spin" : "h-3 w-3"}
/>
{t("Refresh")}
</button>
<Link
href="/settings/memory"
className="inline-flex items-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 transition hover:bg-[var(--muted)]"
>
{t("Memory settings")}
</Link>
</div>
</header>
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
<LayerCard
href="/memory/l1"
icon={Layers}
title={t("L1 · Workspace mirror")}
tag={t("Live")}
stat={l1Total === null ? "…" : l1Total.toLocaleString()}
statLabel={t("entities tracked")}
detail={t(
"Snapshot of your live workspace across {{n}} surfaces. Refresh to record changes.",
{ n: SURFACES.length },
)}
/>
<LayerCard
href="/memory/l2"
icon={Workflow}
title={t("L2 · Per-surface summaries")}
tag={t("Curated")}
stat={l2Total.toLocaleString()}
statLabel={t("facts across {{n}} surfaces", {
n: l2Docs.length || SURFACES.length,
})}
detail={t(
"Surface-specific facts extracted by the consolidator. Run Update / Audit / Dedup per doc.",
)}
/>
<LayerCard
href="/memory/l3"
icon={Network}
title={t("L3 · Cross-surface knowledge")}
tag={t("Synthesis")}
stat={l3Total.toLocaleString()}
statLabel={t("propositions across {{n}} slots", {
n: l3Docs.length || L3_VISIBLE.length,
})}
detail={t(
"Cross-surface synthesis: profile, recent timeline, knowledge scope. Hedged claims with L2 evidence.",
)}
/>
</div>
<GraphCallout />
<MemoryArchivedBanner latestBackup={latestBackup} variant="compact" />
</div>
);
}
function GraphCallout() {
const { t } = useTranslation();
return (
<Link
href="/memory/graph"
className="group relative block overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 transition hover:-translate-y-[1px] hover:border-[var(--primary)]/40 hover:shadow-sm"
>
<div
className="pointer-events-none absolute inset-0 opacity-80"
style={{
background:
"radial-gradient(ellipse 60% 80% at 92% 50%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 70%)",
}}
/>
<div className="relative flex items-center gap-5">
<span className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<Sparkles className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-[15px] font-semibold text-[var(--foreground)]">
{t("Memory graph")}
</h3>
<span className="rounded-full border border-[var(--border)] bg-[var(--background)]/60 px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("New")}
</span>
</div>
<p className="mt-1 text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{t(
"See all three layers at once — L3 synthesis at the centre, L2 facts in the middle, L1 traces on the outside. Hover any node for a preview.",
)}
</p>
</div>
<ArrowRight className="hidden h-4 w-4 shrink-0 text-[var(--primary)] transition group-hover:translate-x-0.5 md:block" />
</div>
</Link>
);
}
interface LayerCardProps {
href: string;
icon: LucideIcon;
title: string;
tag: string;
stat: string;
statLabel: string;
detail: string;
}
function LayerCard({
href,
icon: Icon,
title,
tag,
stat,
statLabel,
detail,
}: LayerCardProps) {
return (
<Link
href={href}
className="group flex flex-col gap-4 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 transition hover:-translate-y-[1px] hover:border-[var(--primary)]/40 hover:shadow-sm"
>
<div className="flex items-start justify-between">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-[var(--primary)]/10 text-[var(--primary)]">
<Icon className="h-4 w-4" />
</span>
<span className="rounded-full border border-[var(--border)] bg-[var(--background)] px-2 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{tag}
</span>
</div>
<div className="space-y-1">
<h2 className="text-[15px] font-semibold text-[var(--foreground)]">
{title}
</h2>
</div>
<div className="flex items-baseline gap-2">
<span className="text-[28px] font-semibold tracking-tight text-[var(--foreground)]">
{stat}
</span>
<span className="text-[12px] text-[var(--muted-foreground)]">
{statLabel}
</span>
</div>
<p className="text-[13px] leading-relaxed text-[var(--muted-foreground)]">
{detail}
</p>
<div className="mt-auto inline-flex items-center gap-1 text-[12px] font-medium text-[var(--primary)] opacity-0 transition group-hover:opacity-100">
<span>{tag}</span>
<ArrowRight className="h-3.5 w-3.5" />
</div>
</Link>
);
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
BookOpen,
Bot,
ClipboardList,
Layers,
Library,
MessageSquare,
Network,
NotebookPen,
PenLine,
Workflow,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import { L1View } from "@/components/memory/MemorySection";
type Surface =
| "chat"
| "notebook"
| "quiz"
| "kb"
| "book"
| "partner"
| "cowriter";
interface NavEntry {
key: Surface;
label: string;
icon: LucideIcon;
}
const L1_NAV: NavEntry[] = [
{ key: "chat", icon: MessageSquare, label: "Chat" },
{ key: "notebook", icon: NotebookPen, label: "Notebook" },
{ key: "quiz", icon: ClipboardList, label: "Quiz" },
{ key: "kb", icon: BookOpen, label: "Knowledge base" },
{ key: "book", icon: Library, label: "Book" },
{ key: "partner", icon: Bot, label: "Partner" },
{ key: "cowriter", icon: PenLine, label: "Co-writer" },
];
interface SnapshotResponse {
surface: Surface;
entities: unknown[];
pending_changes: unknown[];
}
export interface MemoryL1WorkbenchProps {
initialSurface?: Surface;
initialFocusRef?: string;
}
export default function MemoryL1Workbench({
initialSurface,
initialFocusRef,
}: MemoryL1WorkbenchProps) {
const { t } = useTranslation();
// ``initialSurface`` and ``initialFocusRef`` seed the picker + entity
// highlight on first mount. The URL only drives initial state; once the
// user clicks around the rail it's all local state again.
const [surface, setSurface] = useState<Surface>(initialSurface || "notebook");
const [counts, setCounts] = useState<Record<Surface, number>>(
{} as Record<Surface, number>,
);
const [pending, setPending] = useState<Record<Surface, number>>(
{} as Record<Surface, number>,
);
const [focusRef, setFocusRef] = useState<string | null>(
initialFocusRef ?? null,
);
const [toast, setToast] = useState("");
// Fetch counts + pending badges for the left rail in parallel.
const loadCounts = useCallback(async () => {
const entries = await Promise.all(
L1_NAV.map(async ({ key }) => {
try {
const res = await apiFetch(apiUrl(`/api/v1/memory/snapshot/${key}`));
const data = (await res.json()) as SnapshotResponse;
return [
key,
data?.entities?.length ?? 0,
data?.pending_changes?.length ?? 0,
] as const;
} catch {
return [key, 0, 0] as const;
}
}),
);
setCounts(
Object.fromEntries(entries.map(([k, n]) => [k, n])) as Record<
Surface,
number
>,
);
setPending(
Object.fromEntries(entries.map(([k, , p]) => [k, p])) as Record<
Surface,
number
>,
);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadCounts();
}, [loadCounts]);
// Re-fetch counts when the tab regains focus so the rail stays in sync.
useEffect(() => {
const refetch = () => {
if (typeof document !== "undefined" && document.hidden) return;
void loadCounts();
};
window.addEventListener("focus", refetch);
document.addEventListener("visibilitychange", refetch);
return () => {
window.removeEventListener("focus", refetch);
document.removeEventListener("visibilitychange", refetch);
};
}, [loadCounts]);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(""), 2500);
return () => clearTimeout(id);
}, [toast]);
const nicelabel = useMemo(() => {
const entry = L1_NAV.find((n) => n.key === surface);
return entry ? t(entry.label) : surface;
}, [surface, t]);
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-6 py-4 md:px-10">
<div className="flex items-center justify-between gap-3">
<Breadcrumb label={nicelabel} t={t} />
<LayerSwitcher t={t} />
</div>
<div className="grid min-h-0 flex-1 grid-cols-[180px_minmax(0,1fr)] gap-4">
{/* Left rail */}
<aside className="min-h-0 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-2">
<ul className="space-y-0.5">
{L1_NAV.map(({ key, icon: Icon, label }) => {
const active = key === surface;
const c = counts[key];
const p = pending[key];
return (
<li key={key}>
<button
type="button"
onClick={() => {
setSurface(key);
setFocusRef(null);
}}
className={
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] transition " +
(active
? "bg-[var(--muted)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]")
}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{t(label)}</span>
{p > 0 ? (
<span
className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-400"
title={t("{{n}} pending", { n: p })}
>
{p}
</span>
) : typeof c === "number" ? (
<span className="rounded-full bg-[var(--background)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{c}
</span>
) : null}
</button>
</li>
);
})}
</ul>
</aside>
{/* Center: snapshot / changes / kb-queries preview */}
<section className="min-h-0 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5">
<L1View
surface={surface}
onSurfaceChange={(s) => {
setSurface(s as Surface);
setFocusRef(null);
}}
focusRef={focusRef}
onClearFocus={() => setFocusRef(null)}
onToast={setToast}
t={t}
compact
/>
</section>
</div>
{toast && (
<div className="self-start rounded-md border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1 text-[11.5px] text-[var(--primary)]">
{toast}
</div>
)}
</div>
);
}
function LayerSwitcher({ t }: { t: (k: string) => string }) {
// L1 is the current page, so it stays as a non-link pill; L2 + L3
// link to their respective hubs.
const entries: {
key: "L1" | "L2" | "L3";
href: string;
icon: LucideIcon;
label: string;
}[] = [
{ key: "L1", href: "/memory/l1", icon: Layers, label: t("L1") },
{ key: "L2", href: "/memory/l2", icon: Workflow, label: t("L2") },
{ key: "L3", href: "/memory/l3", icon: Network, label: t("L3") },
];
return (
<nav className="flex items-center gap-0.5 rounded-full border border-[var(--border)] bg-[var(--card)] p-0.5">
{entries.map(({ key, href, icon: Icon, label }) => {
const active = key === "L1";
if (active) {
return (
<span
key={key}
className="inline-flex items-center gap-1 rounded-full bg-[var(--muted)] px-2.5 py-0.5 text-[11.5px] font-medium text-[var(--foreground)]"
>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
return (
<Link
key={key}
href={href}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]"
>
<Icon className="h-3 w-3" />
{label}
</Link>
);
})}
</nav>
);
}
function Breadcrumb({ label, t }: { label: string; t: (k: string) => string }) {
return (
<div className="flex items-center gap-2 text-[12.5px]">
<Link
href="/memory"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("Memory")}
</Link>
<span className="text-[var(--muted-foreground)]">/</span>
<span className="inline-flex items-center gap-1.5 text-[var(--foreground)]">
<Layers className="h-3.5 w-3.5" />
{t("L1 · Workspace mirror")}
</span>
<span className="text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">{label}</span>
</div>
);
}
+942
View File
@@ -0,0 +1,942 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
Bot,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleSlash,
GitBranch,
Loader2,
Octagon,
PlayCircle,
RotateCcw,
ScanSearch,
Send,
Sparkles,
Trash2,
Undo2,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
listLLMOptions,
llmSelectionKey,
sameLLMSelection,
type LLMOption,
} from "@/lib/llm-options";
import type { LLMSelection } from "@/lib/unified-ws";
import { apiFetch, apiUrl } from "@/lib/api";
import {
useMemoryRun,
type RunEvent,
type RunMode,
} from "@/components/memory/useMemoryRun";
interface MemoryRunPanelProps {
layer: "L2" | "L3";
docKey: string;
onRunComplete?: () => void;
onDocUpdated?: () => void;
}
interface MemorySettingsDTO {
update: { l2_budget: number; l3_budget: number };
audit: { l2_budget: number; l3_budget: number };
dedup: { iterations: number; auto_after_update: boolean };
}
const MODE_META: { key: RunMode; icon: LucideIcon; tKey: string }[] = [
{ key: "update", icon: Sparkles, tKey: "Update memory" },
{ key: "audit", icon: ScanSearch, tKey: "Audit memory" },
{ key: "dedup", icon: GitBranch, tKey: "Dedup" },
];
export default function MemoryRunPanel({
layer,
docKey,
onRunComplete,
onDocUpdated,
}: MemoryRunPanelProps) {
const { t, i18n } = useTranslation();
const { run, events, status, error, isRunning, start, cancel, undo, clear } =
useMemoryRun(layer, docKey);
const [mode, setMode] = useState<RunMode>("update");
// Per-mode overrides keep typed values stable while the user flips between
// modes — each mode has its own input value, defaulting to the settings
// value when no override exists. Avoids setState-in-effect entirely.
const [overrides, setOverrides] = useState<Record<RunMode, number | null>>({
update: null,
audit: null,
dedup: null,
});
const budgetOverride = mode === "dedup" ? null : overrides[mode];
const iterationsOverride = mode === "dedup" ? overrides.dedup : null;
const setBudgetOverride = useCallback(
(v: number | null) => setOverrides((prev) => ({ ...prev, [mode]: v })),
[mode],
);
const setIterationsOverride = useCallback(
(v: number | null) => setOverrides((prev) => ({ ...prev, dedup: v })),
[],
);
const [selection, setSelection] = useState<LLMSelection | null>(null);
const [activeDefault, setActiveDefault] = useState<LLMSelection | null>(null);
const [modelOptions, setModelOptions] = useState<LLMOption[]>([]);
const [modelLoading, setModelLoading] = useState(true);
const [modelError, setModelError] = useState(false);
const [settings, setSettings] = useState<MemorySettingsDTO | null>(null);
// Load LLM options + memory settings once.
useEffect(() => {
setModelLoading(true);
void (async () => {
try {
const data = await listLLMOptions();
setModelOptions(data.options);
setActiveDefault(data.active);
setModelError(false);
} catch {
setModelOptions([]);
setModelError(true);
} finally {
setModelLoading(false);
}
})();
void (async () => {
const res = await apiFetch(apiUrl("/api/v1/memory/settings"));
const data = (await res.json()) as MemorySettingsDTO;
setSettings(data);
})();
}, []);
const defaultBudget = useMemo(() => {
if (!settings) return null;
if (mode === "update") {
return layer === "L2"
? settings.update.l2_budget
: settings.update.l3_budget;
}
if (mode === "audit") {
return layer === "L2"
? settings.audit.l2_budget
: settings.audit.l3_budget;
}
return null;
}, [settings, mode, layer]);
const defaultIterations = settings?.dedup.iterations ?? null;
const budget = budgetOverride ?? defaultBudget ?? ("" as const);
const iterations = iterationsOverride ?? defaultIterations ?? ("" as const);
const effectiveSelection = useMemo(
() => selection ?? activeDefault ?? null,
[selection, activeDefault],
);
const handleRun = useCallback(() => {
if (isRunning) return;
const llmSelection = effectiveSelection
? {
profile_id: effectiveSelection.profile_id,
model_id: effectiveSelection.model_id,
}
: null;
if (mode === "dedup") {
void start({
mode,
iterations: typeof iterations === "number" ? iterations : undefined,
llmSelection,
language: i18n.language || "en",
});
} else {
void start({
mode,
budget: typeof budget === "number" ? budget : undefined,
llmSelection,
language: i18n.language || "en",
});
}
}, [
isRunning,
mode,
iterations,
budget,
effectiveSelection,
i18n.language,
start,
]);
// Notify parent when a run finishes (success or otherwise) so the doc
// preview can refresh.
const lastCompleted = useRef<string | null>(null);
useEffect(() => {
if (!run || isRunning) return;
if (
run.status === "done" ||
run.status === "cancelled" ||
run.status === "error"
) {
if (lastCompleted.current !== run.id) {
lastCompleted.current = run.id;
onRunComplete?.();
}
}
}, [run, isRunning, onRunComplete]);
const lastDocEvent = useRef<number | null>(null);
useEffect(() => {
if (!onDocUpdated) return;
const latest = [...events]
.reverse()
.find(
(ev) =>
ev.payload.stage === "doc_updated" ||
ev.payload.stage === "undo_applied",
);
if (!latest || lastDocEvent.current === latest.seq) return;
lastDocEvent.current = latest.seq;
onDocUpdated();
}, [events, onDocUpdated]);
const undoDepth = useMemo(() => {
let depth = run?.undo_count ?? 0;
for (const ev of events) {
const value = ev.payload.undo_depth;
if (
(ev.payload.stage === "doc_updated" ||
ev.payload.stage === "undo_applied") &&
typeof value === "number"
) {
depth = value;
}
}
return depth;
}, [events, run?.undo_count]);
const handleUndo = useCallback(() => {
if (isRunning || undoDepth <= 0) return;
void undo();
}, [isRunning, undo, undoDepth]);
const handleReset = useCallback(async () => {
if (isRunning) return;
const ok =
typeof window !== "undefined" &&
window.confirm(
t(
"Reset will delete the current memory file AND its seen-id state. The next Update will re-ingest every L1 entity from scratch. Continue?",
),
);
if (!ok) return;
try {
const res = await apiFetch(
apiUrl(
`/api/v1/memory/doc/${layer}/${encodeURIComponent(docKey)}/reset`,
),
{ method: "POST" },
);
if (!res.ok) {
const detail = await res.text();
throw new Error(
detail || t("reset failed: {{status}}", { status: res.status }),
);
}
// Clear local run trace + tell the parent workbench to re-fetch the
// (now empty) doc, line view, and overview badge.
clear();
onDocUpdated?.();
} catch (e) {
if (typeof window !== "undefined") {
window.alert(
t("Reset failed: {{msg}}", {
msg: e instanceof Error ? e.message : t("unknown error"),
}),
);
}
}
}, [isRunning, t, layer, docKey, clear, onDocUpdated]);
const turns = useMemo(() => groupByTurn(events), [events]);
return (
<div className="flex h-full min-h-0 flex-col rounded-2xl border border-[var(--border)] bg-[var(--card)]">
{/* Header */}
<header className="flex items-center justify-between gap-2 border-b border-[var(--border)] px-3 py-2">
<div className="flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--foreground)]">
<Bot className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
{t("LLM workspace")}
</div>
<div className="flex items-center gap-1">
{run && events.length > 0 && (
<>
<button
type="button"
onClick={handleUndo}
disabled={isRunning || undoDepth <= 0}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-40"
title={t("Undo last memory edit")}
>
<Undo2 className="h-3 w-3" />
{undoDepth > 0 && <span>{undoDepth}</span>}
</button>
<button
type="button"
onClick={clear}
disabled={isRunning}
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-40"
title={t("Clear trace")}
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
<button
type="button"
onClick={() => void handleReset()}
disabled={isRunning}
className="inline-flex items-center gap-1 rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 hover:bg-amber-500/20 hover:text-amber-800 disabled:opacity-40 dark:text-amber-300 dark:hover:text-amber-200"
title={t("Reset memory (delete md + seen-id state)")}
>
<RotateCcw className="h-3 w-3" />
<span>{t("Reset")}</span>
</button>
</div>
</header>
{/* Composer — two evenly-distributed rows pinned at the top */}
<div className="space-y-2 border-b border-[var(--border)] bg-[var(--background)]/40 px-3 py-2.5">
<div className="grid grid-cols-3 gap-1.5">
{MODE_META.map(({ key, icon: Icon, tKey }) => (
<button
key={key}
type="button"
disabled={isRunning}
onClick={() => setMode(key)}
className={
"inline-flex min-w-0 items-center justify-center gap-1 rounded-full border border-[var(--border)] px-2 py-1 text-[11.5px] transition disabled:opacity-50 " +
(mode === key
? "bg-[var(--muted)] text-[var(--foreground)]"
: "bg-[var(--background)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]")
}
>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{t(tKey)}</span>
</button>
))}
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1.5">
{mode === "dedup" ? (
<NumberInput
value={iterations}
setValue={(v) => setIterationsOverride(v === "" ? null : v)}
label={t("Iter")}
min={1}
max={20}
disabled={isRunning}
fullWidth
/>
) : (
<NumberInput
value={budget}
setValue={(v) => setBudgetOverride(v === "" ? null : v)}
label={t("Budget")}
min={1}
max={200}
disabled={isRunning}
fullWidth
/>
)}
<ModelPill
options={modelOptions}
value={selection ?? activeDefault}
loading={modelLoading}
error={modelError}
disabled={isRunning}
onChange={(next) => setSelection(next)}
t={t}
/>
{isRunning ? (
<button
type="button"
onClick={() => void cancel()}
className="inline-flex h-7 w-9 items-center justify-center rounded-full bg-[var(--muted)] text-[var(--foreground)] transition hover:opacity-90"
title={t("Cancel")}
>
<Octagon className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={handleRun}
className="inline-flex h-7 w-9 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)] transition hover:opacity-90"
title={t("Run")}
>
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Stream */}
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{events.length === 0 && status === "idle" ? (
<EmptyTrace t={t} />
) : (
<ol className="space-y-2">
{turns.map((turn) => (
<TurnCard key={`${turn.kind}-${turn.id}`} turn={turn} t={t} />
))}
{isRunning && (
<li className="flex items-center gap-2 rounded-md bg-[var(--muted)]/40 px-2.5 py-1.5 text-[11.5px] text-[var(--muted-foreground)]">
<Loader2 className="h-3 w-3 animate-spin" />
{t("Working…")}
</li>
)}
{error && (
<li className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-2.5 py-1.5 text-[11.5px] text-red-500">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{error}
</li>
)}
</ol>
)}
</div>
</div>
);
}
// ── Trace pieces ─────────────────────────────────────────────────────
type Turn =
| { kind: "system"; id: string; ts: string; payload: RunEvent["payload"] }
| {
kind: "llm";
id: string;
ts: string;
turn: number;
chunkIndex: number | null;
system_prompt: string;
user_prompt: string;
response: string;
error: string | null;
label: string | null;
};
function groupByTurn(events: RunEvent[]): Turn[] {
const out: Turn[] = [];
const pending: Record<string, Turn & { kind: "llm" }> = {};
for (const ev of events) {
const p = ev.payload as Record<string, unknown>;
const stage = String(p.stage || "");
if (stage === "llm_io_start") {
const turn = typeof p.turn === "number" ? p.turn : 0;
const chunkIndex =
typeof p.chunk_index === "number" ? (p.chunk_index as number) : null;
const key = `${turn}:${chunkIndex}:${ev.seq}`;
const card: Turn & { kind: "llm" } = {
kind: "llm",
id: key,
ts: ev.ts,
turn,
chunkIndex,
system_prompt: String(p.system_prompt || ""),
user_prompt: String(p.user_prompt || ""),
response: "",
error: null,
label: typeof p.label === "string" ? p.label : null,
};
pending[`${turn}:${chunkIndex}`] = card;
out.push(card);
continue;
}
if (stage === "llm_io_end") {
const turn = typeof p.turn === "number" ? p.turn : 0;
const chunkIndex =
typeof p.chunk_index === "number" ? (p.chunk_index as number) : null;
const card = pending[`${turn}:${chunkIndex}`];
if (card) {
card.response = String(p.response || "");
card.error = typeof p.error === "string" ? p.error : null;
delete pending[`${turn}:${chunkIndex}`];
}
continue;
}
if (stage === "llm_io_delta") {
const turn = typeof p.turn === "number" ? p.turn : 0;
const chunkIndex =
typeof p.chunk_index === "number" ? (p.chunk_index as number) : null;
const card = pending[`${turn}:${chunkIndex}`];
if (card) {
card.response += String(p.delta || "");
}
continue;
}
out.push({
kind: "system",
id: `sys-${ev.seq}`,
ts: ev.ts,
payload: ev.payload,
});
}
return out;
}
function TurnCard({ turn, t }: { turn: Turn; t: (k: string) => string }) {
if (turn.kind === "system") {
return <SystemEventRow event={turn.payload} t={t} />;
}
return <LLMTurnCard turn={turn} t={t} />;
}
function SystemEventRow({
event,
t,
}: {
event: RunEvent["payload"];
t: (k: string) => string;
}) {
const stage = String(event.stage || "");
const display = systemEventDisplay(stage, event, t);
if (!display) return null;
return (
<li
className={
"flex items-start gap-2 rounded-md px-2 py-1.5 text-[11.5px] " +
(display.tone === "ok"
? "border border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300"
: display.tone === "warn"
? "border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300"
: "bg-[var(--muted)]/40 text-[var(--muted-foreground)]")
}
>
<display.icon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<div className="min-w-0">
<div className="font-mono text-[10.5px] uppercase tracking-wide">
{display.title}
</div>
{display.detail && (
<div className="mt-0.5 break-words leading-snug">
{display.detail}
</div>
)}
</div>
</li>
);
}
function systemEventDisplay(
stage: string,
event: RunEvent["payload"],
t: (k: string) => string,
): {
icon: LucideIcon;
title: string;
detail: string;
tone: "ok" | "warn" | "muted";
} | null {
const num = (k: string) =>
typeof event[k] === "number" ? String(event[k]) : null;
const str = (k: string) =>
typeof event[k] === "string" ? String(event[k]) : null;
switch (stage) {
case "run_started":
return {
icon: PlayCircle,
title: t("Run started"),
detail: `${event.mode}`,
tone: "muted",
};
case "trace_loaded": {
const total = num("total") ?? num("total_l2_entries");
const fresh = num("new") ?? num("new_l2_entries");
const parts = [
total ? `total=${total}` : null,
fresh ? `new=${fresh}` : null,
].filter(Boolean);
return {
icon: PlayCircle,
title: t("Traces loaded"),
detail: parts.join(" · "),
tone: "muted",
};
}
case "chunked":
return {
icon: PlayCircle,
title: t("Chunked"),
detail: [
num("chunks") ? `chunks=${num("chunks")}` : null,
num("budget") ? `budget=${num("budget")}` : null,
num("chars") ? `chars=${num("chars")}` : null,
]
.filter(Boolean)
.join(" · "),
tone: "muted",
};
case "progress": {
const turn = num("turn");
const total = num("total");
return {
icon: PlayCircle,
title: t("Progress"),
detail: turn && total ? `${turn}/${total}` : "",
tone: "muted",
};
}
case "facts_extracted":
return {
icon: CheckCircle2,
title: t("Facts extracted"),
detail: [
num("kept") ? `kept=${num("kept")}` : null,
num("added") ? `added=${num("added")}` : null,
]
.filter(Boolean)
.join(" · "),
tone: "ok",
};
case "refs_dropped":
return {
icon: CircleSlash,
title: t("Ref dropped"),
detail: `${str("reason") || "?"} :: ${str("text") || ""}`,
tone: "warn",
};
case "op_applied":
return {
icon: CheckCircle2,
title: t("Edit applied"),
detail: [str("op"), str("detail")].filter(Boolean).join(" · "),
tone: "ok",
};
case "op_rejected":
return {
icon: CircleSlash,
title: t("Edit rejected"),
detail: [str("op"), str("detail")].filter(Boolean).join(" · "),
tone: "warn",
};
case "doc_updated":
return {
icon: CheckCircle2,
title: t("Markdown updated"),
detail: [
str("action"),
num("turn") ? `turn=${num("turn")}` : null,
num("undo_depth") ? `undo=${num("undo_depth")}` : null,
]
.filter(Boolean)
.join(" · "),
tone: "ok",
};
case "undo_applied":
return {
icon: Undo2,
title: t("Undo applied"),
detail: [
str("action"),
num("undo_depth") ? `remaining=${num("undo_depth")}` : "remaining=0",
]
.filter(Boolean)
.join(" · "),
tone: "warn",
};
case "done":
return {
icon: CheckCircle2,
title: t("Done"),
detail: [
num("facts_added") ? `+${num("facts_added")} facts` : null,
num("edits_applied") ? `+${num("edits_applied")} edits` : null,
num("refs_dropped") ? `dropped=${num("refs_dropped")}` : null,
]
.filter(Boolean)
.join(" · "),
tone: "ok",
};
case "run_ended":
return {
icon: CheckCircle2,
title: t("Run ended"),
detail: str("status") || "",
tone: "muted",
};
case "cancelled":
return {
icon: CircleSlash,
title: t("Cancelled"),
detail: "",
tone: "warn",
};
case "error":
return {
icon: AlertCircle,
title: t("Error"),
detail: str("message") || "",
tone: "warn",
};
default:
return null;
}
}
function LLMTurnCard({
turn,
t,
}: {
turn: Extract<Turn, { kind: "llm" }>;
t: (k: string) => string;
}) {
const [systemOpen, setSystemOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false);
const tag =
turn.chunkIndex !== null
? `t${turn.turn} · chunk ${turn.chunkIndex + 1}`
: `t${turn.turn}`;
return (
<li className="space-y-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-2">
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-wide text-[var(--muted-foreground)]">
<Bot className="h-3 w-3" />
<span>{turn.label || "llm"}</span>
<span>·</span>
<span>{tag}</span>
</div>
<Disclosure
open={systemOpen}
setOpen={setSystemOpen}
label={t("System prompt")}
body={turn.system_prompt}
/>
<Disclosure
open={userOpen}
setOpen={setUserOpen}
label={t("User prompt")}
body={turn.user_prompt}
/>
<div className="text-[12px] text-[var(--foreground)]">
{turn.response ? (
<pre className="whitespace-pre-wrap break-words font-mono text-[11.5px] leading-relaxed">
{turn.response}
</pre>
) : turn.error ? (
<div className="rounded border border-red-500/30 bg-red-500/5 px-2 py-1 text-[11px] text-red-500">
{turn.error}
</div>
) : (
<div className="flex items-center gap-1.5 text-[11.5px] text-[var(--muted-foreground)]">
<Loader2 className="h-3 w-3 animate-spin" />
{t("Streaming…")}
</div>
)}
</div>
</li>
);
}
function Disclosure({
open,
setOpen,
label,
body,
}: {
open: boolean;
setOpen: (v: boolean) => void;
label: string;
body: string;
}) {
if (!body) return null;
return (
<div className="text-[11px] text-[var(--muted-foreground)]">
<button
type="button"
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 rounded px-1 py-0.5 hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]"
>
{open ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
{label}
<span className="opacity-60">·&nbsp;{body.length}</span>
</button>
{open && (
<pre className="mt-1 max-h-60 overflow-y-auto whitespace-pre-wrap break-words rounded bg-[var(--muted)]/40 px-2 py-1.5 font-mono text-[10.5px] text-[var(--foreground)]">
{body}
</pre>
)}
</div>
);
}
// ── Composer pieces ─────────────────────────────────────────────────
function NumberInput({
value,
setValue,
label,
min,
max,
disabled,
fullWidth = false,
}: {
value: number | "";
setValue: (n: number | "") => void;
label: string;
min: number;
max: number;
disabled: boolean;
fullWidth?: boolean;
}) {
return (
<label
className={
"flex items-center justify-between gap-1 rounded-full border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[11.5px] text-[var(--muted-foreground)] " +
(fullWidth ? "w-full" : "")
}
>
<span>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
disabled={disabled}
onChange={(e) => {
const v = e.target.value;
if (v === "") return setValue("");
const n = parseInt(v, 10);
if (!Number.isNaN(n)) setValue(Math.max(min, Math.min(max, n)));
}}
className="w-14 bg-transparent text-right text-[11.5px] text-[var(--foreground)] outline-none"
/>
</label>
);
}
interface ModelPillProps {
options: LLMOption[];
value: LLMSelection | null;
loading: boolean;
error: boolean;
disabled: boolean;
onChange: (next: LLMSelection | null) => void;
t: (k: string) => string;
}
function ModelPill({
options,
value,
loading,
error,
disabled,
onChange,
t,
}: ModelPillProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const selectedOption = useMemo(
() => options.find((o) => sameLLMSelection(o, value)) ?? null,
[options, value],
);
const label = loading
? t("Loading models")
: error
? t("Models unavailable")
: selectedOption?.model_name || t("Default model");
const inactive = disabled || loading || error || options.length === 0;
return (
<div ref={rootRef} className="relative w-full min-w-0">
<button
type="button"
disabled={inactive}
onClick={() => setOpen(!open)}
title={
selectedOption
? `${selectedOption.profile_name} | ${selectedOption.provider}`
: label
}
className={
"flex w-full min-w-0 items-center gap-1 rounded-full border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[11.5px] text-[var(--foreground)] transition hover:bg-[var(--muted)]/60 disabled:opacity-50 " +
(open ? "border-[var(--primary)]/40" : "")
}
>
<Bot className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
<span className="flex-1 truncate text-left">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-[var(--muted-foreground)]" />
</button>
{open && !inactive && (
<div className="absolute right-0 top-full z-30 mt-1 max-h-72 w-[min(320px,calc(100vw-32px))] overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] p-1 shadow-lg">
{options.map((opt) => {
const active = sameLLMSelection(opt, value);
return (
<button
key={llmSelectionKey(opt)}
type="button"
onClick={() => {
onChange({
profile_id: opt.profile_id,
model_id: opt.model_id,
});
setOpen(false);
}}
className={
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-[11.5px] transition hover:bg-[var(--muted)] " +
(active
? "bg-[var(--muted)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)]")
}
>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-[var(--foreground)]">
{opt.model_name}
</span>
<span className="shrink-0 text-[10px] opacity-60">
{opt.provider}
</span>
</span>
{opt.is_active_default && (
<span className="shrink-0 rounded bg-[var(--primary)]/10 px-1 py-0.5 text-[9.5px] uppercase text-[var(--primary)]">
{t("default")}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
function EmptyTrace({ t }: { t: (k: string) => string }) {
return (
<div className="grid h-full place-items-center text-center text-[12.5px] text-[var(--muted-foreground)]">
<div className="max-w-xs space-y-1.5">
<Bot className="mx-auto h-6 w-6 opacity-60" />
<p>
{t(
"Pick a mode and click Run. The LLM trace — system prompt, user prompt, response — appears here, turn by turn.",
)}
</p>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+639
View File
@@ -0,0 +1,639 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
BookOpen,
Bot,
ClipboardList,
FileText,
Hash,
Layers,
Library,
Loader2,
MessageSquare,
Network,
NotebookPen,
PenLine,
Pencil,
Save,
Workflow,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import MemoryRunPanel from "@/components/memory/MemoryRunPanel";
const MarkdownRenderer = dynamic(
() => import("@/components/common/MarkdownRenderer"),
{ ssr: false },
);
type Layer = "L2" | "L3";
// Each bullet ends with an HTML comment carrying the entry id
// (``<!--m_01HZK...-->``). The parser round-trips it but the rendered
// view should not show it as text — and we want clicking a same-doc
// m_xxx footnote to scroll the user back to the citing bullet. Convert
// the comment to an inline span with an ``id`` so:
// * the rendered output is visually empty (zero-width span)
// * ``#m_xxx`` hash navigation works within the doc
// Requires ``allowHtml`` on the markdown renderer (we set it below).
const _ENTRY_ANCHOR_RE = /\s*<!--\s*(m_[0-9A-HJKMNP-TV-Z]{26})\s*-->/g;
// Rewrite footnote definitions so the ref text becomes a markdown link.
//
// Four link targets, picked by (ref shape, current layer):
// * ``surface:id`` → ``/memory/l1?ref=surface:id`` (L2 → L1 hop)
// * ``m_<ULID>`` in L2 → ``#m_<ULID>`` (same-doc scroll; anchor
// span above provides the target)
// * ``m_<ULID>`` in L3 → ``/memory/resolve?id=m_<ULID>``
// (legacy pre-pivot doc; resolver page redirects to the right L2)
// * bare ``<surface>`` → ``/memory/l2/<surface>`` (L3 → L2 hop;
// new design, L3 cites L2 files not L2 entries)
//
// The label regex is intentionally loose so this hits BOTH layouts —
// the new consolidated ``[^1]:`` and the legacy entry-keyed
// ``[^m_xxx]:`` — so docs that pre-date the merge step still get
// clickable footnotes until the next mode pass migrates them.
const _FOOTNOTE_DEF_LINKIFY_SURFACE_REF_RE =
/^(\[\^[^\]]+\]:\s*)([a-z][a-z0-9_-]*):([A-Za-z0-9_-]+)\s*$/gm;
const _FOOTNOTE_DEF_LINKIFY_ENTRY_RE =
/^(\[\^[^\]]+\]:\s*)(m_[0-9A-HJKMNP-TV-Z]{26})\s*$/gm;
// Bare surface name — keep tight (whitelist) so a typo can't accidentally
// turn into an L2 hub link.
const _L3_SURFACES = new Set([
"chat",
"notebook",
"quiz",
"kb",
"book",
"partner",
"cowriter",
]);
const _FOOTNOTE_DEF_LINKIFY_BARE_RE =
/^(\[\^[^\]]+\]:\s*)([a-z][a-z0-9_-]*)\s*$/gm;
function prepareDocForRender(md: string, layer: Layer): string {
// Anchor injection MUST come before linkify so that ``m_xxx`` text on
// a footnote-definition line is matched as a ref, not chewed up by the
// anchor regex (which only matches inside HTML comments).
const withAnchors = md.replace(
_ENTRY_ANCHOR_RE,
' <span id="$1" class="memory-entry-anchor"></span>',
);
// ``surface:id`` must be tried before bare-surface because the bare
// regex would otherwise match the surface prefix and leave ``:id``
// dangling.
const withSurfaceLinks = withAnchors.replace(
_FOOTNOTE_DEF_LINKIFY_SURFACE_REF_RE,
(_match, prefix: string, surface: string, entityId: string) => {
const ref = `${surface}:${entityId}`;
const url = `/memory/l1?ref=${encodeURIComponent(ref)}`;
return `${prefix}[${ref}](${url})`;
},
);
const withEntryLinks = withSurfaceLinks.replace(
_FOOTNOTE_DEF_LINKIFY_ENTRY_RE,
(_match, prefix: string, entryId: string) => {
// L2: entry id refers to a bullet in *this* doc → local anchor.
// L3: entry id refers to a bullet in some L2 doc → resolver page
// does the surface lookup, then redirects.
const href =
layer === "L2"
? `#${entryId}`
: `/memory/resolve?id=${encodeURIComponent(entryId)}`;
return `${prefix}[${entryId}](${href})`;
},
);
// Bare surface name — only meaningful for L3 refs (new design).
// Whitelist guards against linkifying arbitrary words that happen to
// end up alone on a footnote definition line.
return withEntryLinks.replace(
_FOOTNOTE_DEF_LINKIFY_BARE_RE,
(match, prefix: string, name: string) => {
if (!_L3_SURFACES.has(name)) return match;
return `${prefix}[${name}](/memory/l2/${name})`;
},
);
}
interface DocResponse {
layer: Layer;
key: string;
content: string;
}
interface LineRowDTO {
number: number;
kind: "title" | "blank" | "section" | "bullet";
text: string;
entry_id: string | null;
section: string | null;
}
interface DocOverview {
layer: Layer;
key: string;
exists: boolean;
updated_at: string | null;
entry_count: number;
backlog: number;
}
interface NavEntry {
key: string;
label: string;
icon: LucideIcon;
}
const L2_NAV: NavEntry[] = [
{ key: "chat", icon: MessageSquare, label: "Chat" },
{ key: "notebook", icon: NotebookPen, label: "Notebook" },
{ key: "quiz", icon: ClipboardList, label: "Quiz" },
{ key: "kb", icon: BookOpen, label: "Knowledge base" },
{ key: "book", icon: Library, label: "Book" },
{ key: "partner", icon: Bot, label: "Partner" },
{ key: "cowriter", icon: PenLine, label: "Co-writer" },
];
const L3_NAV: NavEntry[] = [
{ key: "recent", icon: Network, label: "Recent summary" },
{ key: "profile", icon: Network, label: "User profile" },
{ key: "scope", icon: Network, label: "Knowledge scope" },
];
type ViewMode = "plain" | "lines";
export interface MemoryWorkbenchProps {
layer: Layer;
initialKey?: string;
/**
* Entry id to scroll into view + briefly highlight after the markdown
* renders. Set by the deep-link contract (``?focus=m_xxx``) when the
* user lands here from an L3 footnote click.
*/
initialFocus?: string;
}
export default function MemoryWorkbench({
layer,
initialKey,
initialFocus,
}: MemoryWorkbenchProps) {
const { t } = useTranslation();
const router = useRouter();
const nav = layer === "L2" ? L2_NAV : L3_NAV;
const [docKey, setDocKey] = useState<string>(initialKey || nav[0].key);
useEffect(() => {
if (initialKey) setDocKey(initialKey);
}, [initialKey]);
const [overview, setOverview] = useState<Record<string, DocOverview>>({});
const [content, setContent] = useState("");
const [lines, setLines] = useState<LineRowDTO[]>([]);
const [view, setView] = useState<ViewMode>("plain");
const [editing, setEditing] = useState(false);
const [editorValue, setEditorValue] = useState("");
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState("");
// Focus is one-shot — once we've scrolled-to + flashed the anchor we
// don't want subsequent content reloads (e.g. after a Run) to keep
// re-scrolling. ``initialFocus`` seeds it; the effect clears it.
const [pendingFocus, setPendingFocus] = useState<string | null>(
initialFocus || null,
);
const previewRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (initialFocus) setPendingFocus(initialFocus);
}, [initialFocus]);
const loadOverview = useCallback(async () => {
const res = await apiFetch(apiUrl("/api/v1/memory/overview"));
const data = await res.json();
const map: Record<string, DocOverview> = {};
for (const d of data.docs || []) {
if (d.layer === layer) map[d.key] = d;
}
setOverview(map);
}, [layer]);
const loadDoc = useCallback(async () => {
const res = await apiFetch(apiUrl(`/api/v1/memory/doc/${layer}/${docKey}`));
const data = (await res.json()) as DocResponse;
setContent(data?.content || "");
setEditorValue(data?.content || "");
}, [layer, docKey]);
const loadLines = useCallback(async () => {
const res = await apiFetch(
apiUrl(`/api/v1/memory/doc/${layer}/${docKey}/lines`),
);
const data = (await res.json()) as { lines: LineRowDTO[] };
setLines(data?.lines || []);
}, [layer, docKey]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
useEffect(() => {
void loadDoc();
void loadLines();
}, [loadDoc, loadLines]);
useEffect(() => {
if (!toast) return;
const id = setTimeout(() => setToast(""), 2200);
return () => clearTimeout(id);
}, [toast]);
// Scroll-to + flash the focused entry once the markdown is in the
// DOM. Markdown renders via a dynamic import → we can't fire on
// ``content`` change alone; wait a frame so the anchor span exists.
// ``editing`` mode hides the rendered preview, so skip then.
useEffect(() => {
if (!pendingFocus || editing) return;
if (!content || !previewRef.current) return;
const anchorId = pendingFocus.startsWith("m_")
? pendingFocus
: `m_${pendingFocus}`;
let cancelled = false;
const id = window.setTimeout(() => {
if (cancelled) return;
const span = document.getElementById(anchorId);
// Highlight the bullet, not the zero-width anchor span.
const li = (span?.closest("li") as HTMLElement | null) ?? span;
if (!li) return;
li.scrollIntoView({ block: "center", behavior: "smooth" });
const prev = {
outline: li.style.outline,
outlineOffset: li.style.outlineOffset,
borderRadius: li.style.borderRadius,
transition: li.style.transition,
};
li.style.outline = "2px solid var(--primary)";
li.style.outlineOffset = "4px";
li.style.borderRadius = "6px";
li.style.transition = "outline-color 1.4s ease-out";
const clear = window.setTimeout(() => {
li.style.outline = prev.outline;
li.style.outlineOffset = prev.outlineOffset;
li.style.borderRadius = prev.borderRadius;
li.style.transition = prev.transition;
}, 1800);
// Consume the focus token so reloads don't re-trigger.
setPendingFocus(null);
return () => window.clearTimeout(clear);
}, 80);
return () => {
cancelled = true;
window.clearTimeout(id);
};
}, [pendingFocus, content, editing]);
const selectDoc = useCallback(
(next: string) => {
if (next === docKey) return;
setDocKey(next);
// Reflect selection in the URL so refresh + share keep state.
router.replace(
layer === "L2" ? `/memory/l2/${next}` : `/memory/l3/${next}`,
);
},
[docKey, layer, router],
);
const saveDoc = useCallback(async () => {
setSaving(true);
try {
await apiFetch(apiUrl(`/api/v1/memory/doc/${layer}/${docKey}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: editorValue }),
});
setContent(editorValue);
setEditing(false);
setToast(t("Saved"));
void loadLines();
} catch (e) {
setToast(e instanceof Error ? e.message : t("Save failed"));
} finally {
setSaving(false);
}
}, [editorValue, layer, docKey, t, loadLines]);
const nicelabel = useMemo(() => {
const entry = nav.find((n) => n.key === docKey);
return entry ? t(entry.label) : docKey;
}, [docKey, nav, t]);
const handleRunComplete = useCallback(() => {
void loadDoc();
void loadLines();
void loadOverview();
}, [loadDoc, loadLines, loadOverview]);
return (
<div className="flex h-full min-h-0 flex-col gap-3 px-6 py-4 md:px-10">
<div className="flex items-center justify-between gap-3">
<Breadcrumb layer={layer} label={nicelabel} t={t} />
<LayerSwitcher current={layer} t={t} />
</div>
<div className="grid min-h-0 flex-1 grid-cols-[180px_minmax(0,1fr)_360px] gap-4">
{/* ── Left rail ── */}
<aside className="min-h-0 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-2">
<ul className="space-y-0.5">
{nav.map(({ key, icon: Icon, label }) => {
const doc = overview[key];
const active = key === docKey;
return (
<li key={key}>
<button
type="button"
onClick={() => selectDoc(key)}
className={
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] text-left transition " +
(active
? "bg-[var(--muted)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]")
}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{t(label)}</span>
{doc?.exists && (
<span className="rounded-full bg-[var(--background)] px-1.5 py-0.5 text-[10px] text-[var(--muted-foreground)]">
{doc.entry_count}
</span>
)}
</button>
</li>
);
})}
</ul>
</aside>
{/* ── Center: preview ── */}
<section className="flex min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<ViewSwitch view={view} setView={setView} t={t} />
{!editing ? (
<button
type="button"
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[12px] transition hover:bg-[var(--muted)]"
>
<Pencil className="h-3.5 w-3.5" />
{t("Edit raw")}
</button>
) : (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setEditorValue(content);
setEditing(false);
}}
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-1 text-[12px]"
>
{t("Cancel")}
</button>
<button
type="button"
onClick={() => void saveDoc()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--primary)] px-3 py-1 text-[12px] text-[var(--primary-foreground)] disabled:opacity-50"
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t("Save")}
</button>
</div>
)}
</div>
<div
ref={previewRef}
className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5"
>
{editing ? (
<textarea
value={editorValue}
onChange={(e) => setEditorValue(e.target.value)}
className="h-[60vh] w-full resize-none rounded-md border border-[var(--border)] bg-[var(--background)] p-3 font-mono text-[12.5px] outline-none focus:border-[var(--primary)]"
/>
) : view === "lines" ? (
<LineNumberedView lines={lines} t={t} />
) : content.trim().length > 0 ? (
// The wrapper hides the default footnote backref arrow
// (``data-footnote-backref``). Without this the rendered
// doc has two clickable elements per footnote: the L1
// text link we inject (correct) and the ↩ icon
// remark-gfm auto-generates that jumps back to the
// citation marker (confusing — users expect every link
// in the footnote to go to L1).
<div className="memory-doc-content [&_.data-footnote-backref]:hidden">
<MarkdownRenderer
content={prepareDocForRender(content, layer)}
variant="prose"
className="text-[14px]"
allowHtml
/>
</div>
) : (
<EmptyState t={t} />
)}
</div>
{toast && (
<div className="self-start rounded-md border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1 text-[11.5px] text-[var(--primary)]">
{toast}
</div>
)}
</section>
{/* ── Right: LLM work area ── */}
<aside className="min-h-0">
<MemoryRunPanel
layer={layer}
docKey={docKey}
onRunComplete={handleRunComplete}
onDocUpdated={handleRunComplete}
/>
</aside>
</div>
</div>
);
}
// ── Sub-components ──────────────────────────────────────────────────
function Breadcrumb({
layer,
label,
t,
}: {
layer: Layer;
label: string;
t: (k: string) => string;
}) {
return (
<div className="flex items-center gap-2 text-[12.5px]">
<Link
href="/memory"
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("Memory")}
</Link>
<span className="text-[var(--muted-foreground)]">/</span>
<span className="inline-flex items-center gap-1.5 text-[var(--foreground)]">
{layer === "L2" ? (
<Workflow className="h-3.5 w-3.5" />
) : (
<Network className="h-3.5 w-3.5" />
)}
{layer === "L2"
? t("L2 · Per-surface summaries")
: t("L3 · Cross-surface knowledge")}
</span>
<span className="text-[var(--muted-foreground)]">/</span>
<span className="text-[var(--foreground)]">{label}</span>
</div>
);
}
function LayerSwitcher({
current,
t,
}: {
current: Layer;
t: (k: string) => string;
}) {
// L1 page is a single workbench (no per-key route); L2/L3 hubs land
// on /memory/l2 and /memory/l3 which list the surfaces / slots.
const entries: {
key: "L1" | Layer;
href: string;
icon: LucideIcon;
label: string;
}[] = [
{ key: "L1", href: "/memory/l1", icon: Layers, label: t("L1") },
{ key: "L2", href: "/memory/l2", icon: Workflow, label: t("L2") },
{ key: "L3", href: "/memory/l3", icon: Network, label: t("L3") },
];
return (
<nav className="flex items-center gap-0.5 rounded-full border border-[var(--border)] bg-[var(--card)] p-0.5">
{entries.map(({ key, href, icon: Icon, label }) => {
const active = key === current;
if (active) {
return (
<span
key={key}
className="inline-flex items-center gap-1 rounded-full bg-[var(--muted)] px-2.5 py-0.5 text-[11.5px] font-medium text-[var(--foreground)]"
>
<Icon className="h-3 w-3" />
{label}
</span>
);
}
return (
<Link
key={key}
href={href}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] text-[var(--muted-foreground)] transition hover:bg-[var(--muted)]/60 hover:text-[var(--foreground)]"
>
<Icon className="h-3 w-3" />
{label}
</Link>
);
})}
</nav>
);
}
function ViewSwitch({
view,
setView,
t,
}: {
view: ViewMode;
setView: (v: ViewMode) => void;
t: (k: string) => string;
}) {
const items: { key: ViewMode; label: string; icon: LucideIcon }[] = [
{ key: "plain", label: t("Rendered"), icon: FileText },
{ key: "lines", label: t("Line numbers"), icon: Hash },
];
return (
<div className="inline-flex items-center gap-1 rounded-lg border border-[var(--border)] bg-[var(--background)] p-1">
{items.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setView(key)}
className={
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11.5px] transition " +
(view === key
? "bg-[var(--muted)] text-[var(--foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/60")
}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
);
}
function LineNumberedView({
lines,
t,
}: {
lines: LineRowDTO[];
t: (k: string) => string;
}) {
if (lines.length === 0) return <EmptyState t={t} />;
const width = Math.max(2, String(lines[lines.length - 1].number).length);
return (
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[12.5px] leading-relaxed text-[var(--foreground)]">
{lines.map((line) => {
const num = String(line.number).padStart(width, " ");
const muted = line.kind === "blank" || line.kind === "title";
return (
<div
key={line.number}
className={
muted
? "text-[var(--muted-foreground)]"
: "text-[var(--foreground)]"
}
>
<span className="select-none pr-3 text-[var(--muted-foreground)]">
{num}:
</span>
{line.text || " "}
</div>
);
})}
</pre>
);
}
function EmptyState({ t }: { t: (k: string) => string }) {
return (
<div className="grid h-[300px] place-items-center text-center text-[13px] text-[var(--muted-foreground)]">
<div className="max-w-sm space-y-2">
<Workflow className="mx-auto h-6 w-6 opacity-60" />
<p>{t("Empty. Click Update to extract facts from your traces.")}</p>
</div>
</div>
);
}
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import i18n from "i18next";
import { apiFetch, apiUrl } from "@/lib/api";
import type { LLMSelection } from "@/lib/unified-ws";
export type RunMode = "update" | "audit" | "dedup";
export type RunStatus = "queued" | "running" | "cancelled" | "done" | "error";
export interface RunHandle {
id: string;
layer: "L2" | "L3";
key: string;
mode: RunMode;
status: RunStatus;
started_at: string;
ended_at: string | null;
error: string | null;
event_count: number;
undo_count: number;
}
export interface RunEventPayload {
stage: string;
[key: string]: unknown;
}
export interface RunEvent {
seq: number;
ts: string;
payload: RunEventPayload;
}
export interface StartArgs {
mode: RunMode;
budget?: number;
iterations?: number;
llmSelection?: LLMSelection | null;
language?: string;
}
interface State {
run: RunHandle | null;
events: RunEvent[];
status: RunStatus | "idle";
error: string | null;
reconnecting: boolean;
}
// One row per `(layer, key)` survives a page refresh by persisting the
// active run_id in localStorage. The hook reads this on mount and tries
// to re-attach to a still-running run on the server.
const STORAGE_PREFIX = "dt:memory:active-run";
function storageKey(layer: string, key: string) {
return `${STORAGE_PREFIX}:${layer}:${key}`;
}
function readPersistedRunId(layer: string, key: string): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(storageKey(layer, key));
}
function writePersistedRunId(layer: string, key: string, runId: string | null) {
if (typeof window === "undefined") return;
if (runId) {
window.localStorage.setItem(storageKey(layer, key), runId);
} else {
window.localStorage.removeItem(storageKey(layer, key));
}
}
export function useMemoryRun(layer: "L2" | "L3", key: string) {
const [state, setState] = useState<State>({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
const abortRef = useRef<AbortController | null>(null);
const cursorRef = useRef<number>(0);
// Track if we've initialised for this (layer, key) so a parent re-render
// doesn't trigger duplicate reconnects.
const initialisedFor = useRef<string>("");
const close = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
// Reset when the doc changes; re-attach to that doc's persisted run if any.
useEffect(() => {
const tag = `${layer}:${key}`;
if (initialisedFor.current === tag) return;
initialisedFor.current = tag;
close();
cursorRef.current = 0;
setState({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
void (async () => {
const persistedId = readPersistedRunId(layer, key);
let runId: string | null = persistedId;
// Verify the persisted run exists; otherwise see if the server thinks
// this doc has an active run.
try {
if (runId) {
const res = await apiFetch(apiUrl(`/api/v1/memory/runs/${runId}`));
if (!res.ok) runId = null;
}
if (!runId) {
const res = await apiFetch(
apiUrl(
`/api/v1/memory/runs?layer=${layer}&key=${encodeURIComponent(key)}`,
),
);
if (res.ok) {
const data = (await res.json()) as { runs: RunHandle[] };
const active = data.runs.find(
(r) => r.status === "running" || r.status === "queued",
);
if (active) {
runId = active.id;
writePersistedRunId(layer, key, runId);
}
}
}
} catch {
// best-effort recovery — if the API is unreachable we'll just start
// a new run when the user clicks Run.
}
if (runId) {
await attach(runId);
}
})();
return () => {
close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layer, key]);
const attach = useCallback(
async (runId: string, since = 0): Promise<void> => {
// Pull the run handle first.
try {
const res = await apiFetch(apiUrl(`/api/v1/memory/runs/${runId}`));
if (!res.ok) {
writePersistedRunId(layer, key, null);
return;
}
const run = (await res.json()) as RunHandle;
setState((s) => ({
...s,
run,
status: run.status,
reconnecting: false,
}));
} catch {
return;
}
abortRef.current?.abort();
const ctl = new AbortController();
abortRef.current = ctl;
cursorRef.current = since;
try {
const res = await apiFetch(
apiUrl(`/api/v1/memory/runs/${runId}/events?since=${since}`),
{ signal: ctl.signal, cache: "no-store" },
);
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (reader) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl = buffer.indexOf("\n\n");
while (nl !== -1) {
const chunk = buffer.slice(0, nl);
buffer = buffer.slice(nl + 2);
const line = chunk
.split("\n")
.find((l) => l.startsWith("data:"))
?.replace(/^data:\s?/, "");
if (line) {
try {
const parsed = JSON.parse(line);
const seq =
typeof parsed.seq === "number"
? parsed.seq
: cursorRef.current;
const ts =
typeof parsed.ts === "string"
? parsed.ts
: new Date().toISOString();
const { seq: _s, ts: _t, ...payload } = parsed;
cursorRef.current = seq + 1;
setState((s) => {
const undoDepth =
typeof payload.undo_depth === "number"
? payload.undo_depth
: null;
const endedStatus =
payload.stage === "run_ended" &&
typeof payload.status === "string"
? (payload.status as RunStatus)
: null;
return {
...s,
status: endedStatus ?? s.status,
run: s.run
? {
...s.run,
status: endedStatus ?? s.run.status,
undo_count: undoDepth ?? s.run.undo_count,
}
: s.run,
events: s.events.some((ev) => ev.seq === seq)
? s.events
: [
...s.events,
{ seq, ts, payload: payload as RunEventPayload },
],
};
});
if (
payload.stage === "run_ended" &&
typeof payload.status === "string"
) {
writePersistedRunId(layer, key, null);
}
} catch {
// ignore malformed chunks
}
}
nl = buffer.indexOf("\n\n");
}
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
setState((s) => ({
...s,
error: e instanceof Error ? e.message : i18n.t("stream failed"),
}));
}
},
[layer, key],
);
const start = useCallback(
async (args: StartArgs): Promise<void> => {
setState((s) => ({ ...s, error: null, events: [], status: "queued" }));
try {
const res = await apiFetch(apiUrl("/api/v1/memory/runs/start"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
layer,
key,
mode: args.mode,
budget: args.budget ?? null,
iterations: args.iterations ?? null,
llm_selection: args.llmSelection ?? null,
language: args.language ?? "en",
}),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(
detail ||
i18n.t("start failed: {{status}}", { status: res.status }),
);
}
const run = (await res.json()) as RunHandle;
writePersistedRunId(layer, key, run.id);
await attach(run.id, 0);
} catch (e) {
setState((s) => ({
...s,
status: "error",
error: e instanceof Error ? e.message : i18n.t("start failed"),
}));
}
},
[layer, key, attach],
);
const cancel = useCallback(async (): Promise<void> => {
if (!state.run) return;
try {
await apiFetch(apiUrl(`/api/v1/memory/runs/${state.run.id}/cancel`), {
method: "POST",
});
} catch {
// ignore: the server may already have terminated naturally.
}
}, [state.run]);
const undo = useCallback(async (): Promise<boolean> => {
if (!state.run) return false;
try {
const res = await apiFetch(
apiUrl(`/api/v1/memory/runs/${state.run.id}/undo`),
{
method: "POST",
},
);
if (!res.ok) {
const detail = await res.text();
throw new Error(
detail || i18n.t("undo failed: {{status}}", { status: res.status }),
);
}
const data = (await res.json()) as {
undo_count?: number;
event?: { seq: number; ts: string; [key: string]: unknown };
};
const ev = data.event;
setState((s) => {
const nextRun = s.run
? { ...s.run, undo_count: data.undo_count ?? s.run.undo_count }
: s.run;
if (!ev || typeof ev.seq !== "number") return { ...s, run: nextRun };
const { seq, ts, ...payload } = ev;
return {
...s,
run: nextRun,
events: s.events.some((item) => item.seq === seq)
? s.events
: [
...s.events,
{
seq,
ts: typeof ts === "string" ? ts : new Date().toISOString(),
payload: payload as RunEventPayload,
},
],
};
});
return true;
} catch (e) {
setState((s) => ({
...s,
error: e instanceof Error ? e.message : i18n.t("undo failed"),
}));
return false;
}
}, [state.run]);
const clear = useCallback(() => {
setState({
run: null,
events: [],
status: "idle",
error: null,
reconnecting: false,
});
writePersistedRunId(layer, key, null);
close();
}, [layer, key, close]);
const isRunning = useMemo(
() => state.status === "queued" || state.status === "running",
[state.status],
);
return {
run: state.run,
events: state.events,
status: state.status,
error: state.error,
isRunning,
start,
cancel,
undo,
clear,
};
}