"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("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>({ 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(null); const [activeDefault, setActiveDefault] = useState(null); const [modelOptions, setModelOptions] = useState([]); const [modelLoading, setModelLoading] = useState(true); const [modelError, setModelError] = useState(false); const [settings, setSettings] = useState(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(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(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 (
{/* Header */}
{t("LLM workspace")}
{run && events.length > 0 && ( <> )}
{/* Composer — two evenly-distributed rows pinned at the top */}
{MODE_META.map(({ key, icon: Icon, tKey }) => ( ))}
{mode === "dedup" ? ( setIterationsOverride(v === "" ? null : v)} label={t("Iter")} min={1} max={20} disabled={isRunning} fullWidth /> ) : ( setBudgetOverride(v === "" ? null : v)} label={t("Budget")} min={1} max={200} disabled={isRunning} fullWidth /> )} setSelection(next)} t={t} /> {isRunning ? ( ) : ( )}
{/* Stream */}
{events.length === 0 && status === "idle" ? ( ) : (
    {turns.map((turn) => ( ))} {isRunning && (
  1. {t("Working…")}
  2. )} {error && (
  3. {error}
  4. )}
)}
); } // ── 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 = {}; for (const ev of events) { const p = ev.payload as Record; 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 ; } return ; } 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 (
  • {display.title}
    {display.detail && (
    {display.detail}
    )}
  • ); } 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; 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 (
  • {turn.label || "llm"} · {tag}
    {turn.response ? (
                {turn.response}
              
    ) : turn.error ? (
    {turn.error}
    ) : (
    {t("Streaming…")}
    )}
  • ); } function Disclosure({ open, setOpen, label, body, }: { open: boolean; setOpen: (v: boolean) => void; label: string; body: string; }) { if (!body) return null; return (
    {open && (
              {body}
            
    )}
    ); } // ── 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 ( ); } 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(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 (
    {open && !inactive && (
    {options.map((opt) => { const active = sameLLMSelection(opt, value); return ( ); })}
    )}
    ); } function EmptyTrace({ t }: { t: (k: string) => string }) { return (

    {t( "Pick a mode and click Run. The LLM trace — system prompt, user prompt, response — appears here, turn by turn.", )}

    ); }