"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 // (````). 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*/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_`` in L2 → ``#m_`` (same-doc scroll; anchor // span above provides the target) // * ``m_`` in L3 → ``/memory/resolve?id=m_`` // (legacy pre-pivot doc; resolver page redirects to the right L2) // * bare ```` → ``/memory/l2/`` (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, ' ', ); // ``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(initialKey || nav[0].key); useEffect(() => { if (initialKey) setDocKey(initialKey); }, [initialKey]); const [overview, setOverview] = useState>({}); const [content, setContent] = useState(""); const [lines, setLines] = useState([]); const [view, setView] = useState("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( initialFocus || null, ); const previewRef = useRef(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 = {}; 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 (
{/* ── Left rail ── */} {/* ── Center: preview ── */}
{!editing ? ( ) : (
)}
{editing ? (