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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">· {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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user