From 05915cc9c5b869480dc2081bc1305336082aeeff Mon Sep 17 00:00:00 2001 From: Xi Zhang Date: Wed, 3 Jun 2026 11:47:45 +0100 Subject: [PATCH] feat: add MemoryPanel component and API for managing global memory files - Introduced MemoryPanel component for viewing and editing EvoScientist's memory files. - Implemented API routes for memory file operations: listing, reading, writing, and deleting files. - Enhanced button styles for better UI consistency. - Integrated memory management into the main application page. --- src/app/api/memory/route.ts | 89 +++ src/app/components/AskUserInterrupt.tsx | 4 +- src/app/components/BetaBadge.tsx | 2 +- src/app/components/ChatInterface.tsx | 16 +- src/app/components/ChatMessage.tsx | 6 +- src/app/components/FileViewDialog.tsx | 2 +- src/app/components/MarkdownContent.tsx | 2 +- src/app/components/MemoryPanel.tsx | 700 +++++++++++++++++++++ src/app/components/SkillsMarketplace.tsx | 4 +- src/app/components/TasksFilesSidebar.tsx | 6 +- src/app/components/ThreadList.tsx | 20 +- src/app/components/WorkspaceFileDialog.tsx | 2 +- src/app/globals.css | 226 +++---- src/app/page.tsx | 3 + src/components/ui/button.tsx | 6 +- src/lib/server/memory.ts | 330 ++++++++++ 16 files changed, 1287 insertions(+), 131 deletions(-) create mode 100644 src/app/api/memory/route.ts create mode 100644 src/app/components/MemoryPanel.tsx create mode 100644 src/lib/server/memory.ts diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts new file mode 100644 index 0000000..f1d0a41 --- /dev/null +++ b/src/app/api/memory/route.ts @@ -0,0 +1,89 @@ +// API for EvoScientist's global memory files (~/.evoscientist/memories). +// GET → list every memory file +// GET ?path= → read one text file's content +// PUT {path, content}→ create or overwrite a text file +// DELETE ?path= → delete a file +// +// All mutations are same-origin only (loopback dev UI, no auth) and pass through +// the traversal/symlink guards in lib/server/memory.ts. + +import { NextRequest, NextResponse } from "next/server"; +import { + listMemory, + readMemory, + writeMemory, + deleteMemory, + isCrossOrigin, +} from "@/lib/server/memory"; + +export const runtime = "nodejs"; + +// Reject oversized request bodies before buffering/parsing them. The precise +// per-content cap lives in writeMemory; this is a coarse upfront guard (a 1MB +// content string JSON-encodes larger, so allow generous headroom). +const MAX_BODY_BYTES = 4 * 1024 * 1024; + +function fail(error: unknown, status = 400) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Memory request failed.", + }, + { status } + ); +} + +export async function GET(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return fail("Cross-origin memory access is not allowed.", 403); + } + const path = request.nextUrl.searchParams.get("path"); + if (path) { + const file = await readMemory(path); + return NextResponse.json(file); + } + return NextResponse.json(await listMemory()); + } catch (error) { + return fail(error); + } +} + +export async function PUT(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return fail("Cross-origin memory access is not allowed.", 403); + } + const declaredLen = Number(request.headers.get("content-length") || 0); + if (declaredLen > MAX_BODY_BYTES) { + return fail("Request body is too large.", 413); + } + const body = (await request.json().catch(() => null)) as { + path?: unknown; + content?: unknown; + } | null; + if (!body || typeof body.path !== "string") { + return fail("A file path is required."); + } + if (typeof body.content !== "string") { + return fail("File content is required."); + } + const file = await writeMemory(body.path, body.content); + return NextResponse.json(file); + } catch (error) { + return fail(error); + } +} + +export async function DELETE(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return fail("Cross-origin memory access is not allowed.", 403); + } + const path = request.nextUrl.searchParams.get("path"); + if (!path) return fail("A file path is required."); + await deleteMemory(path); + return NextResponse.json({ ok: true }); + } catch (error) { + return fail(error); + } +} diff --git a/src/app/components/AskUserInterrupt.tsx b/src/app/components/AskUserInterrupt.tsx index 5f941f4..b236607 100644 --- a/src/app/components/AskUserInterrupt.tsx +++ b/src/app/components/AskUserInterrupt.tsx @@ -92,7 +92,7 @@ export function AskUserInterrupt({ className={cn( "rounded-md border px-3 py-2 text-left text-sm transition-colors", selected - ? "border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)]" + ? "border-[var(--brand-solid)] bg-[var(--brand-solid)] text-[var(--brand-foreground)]" : "border-border bg-background hover:bg-accent" )} > @@ -158,7 +158,7 @@ export function AskUserInterrupt({ size="sm" onClick={() => onSubmit(answers)} disabled={!canSubmit || isLoading} - className="bg-[var(--brand)] text-[var(--brand-foreground)] hover:bg-[var(--brand-hover)]" + className="bg-[var(--brand-solid)] text-[var(--brand-foreground)] hover:opacity-90" > {isLoading ? "Submitting…" : "Submit"} diff --git a/src/app/components/BetaBadge.tsx b/src/app/components/BetaBadge.tsx index 45becba..ee035c9 100644 --- a/src/app/components/BetaBadge.tsx +++ b/src/app/components/BetaBadge.tsx @@ -9,7 +9,7 @@ */ export function BetaBadge() { return ( - + BETA ); diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index 380b6ab..16e727e 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -77,21 +77,21 @@ const getStatusIcon = (status: TodoItem["status"], className?: string) => { return ( ); case "in_progress": return ( ); default: return ( ); } @@ -595,7 +595,7 @@ export const ChatInterface = React.memo(({ assistant }) => { key={prompt} type="button" onClick={() => handleSuggestedPrompt(prompt)} - className="hover:border-primary/40 max-w-full rounded-full border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring" + className="max-w-full rounded-full border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors hover:border-[var(--color-border)] hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring" > {prompt} @@ -688,7 +688,7 @@ export const ChatInterface = React.memo(({ assistant }) => { , (({ assistant }) => { , (({ assistant }) => { > Files (State) - + {Object.keys(files).length} @@ -822,7 +822,7 @@ export const ChatInterface = React.memo(({ assistant }) => { aria-expanded={metaOpen === "files"} > Files (State) - + {Object.keys(files).length} diff --git a/src/app/components/ChatMessage.tsx b/src/app/components/ChatMessage.tsx index df224eb..fa41535 100644 --- a/src/app/components/ChatMessage.tsx +++ b/src/app/components/ChatMessage.tsx @@ -341,7 +341,7 @@ export const ChatMessage = React.memo( {isSubAgentExpanded(subAgent.id) && (
-

+

Input

@@ -351,7 +351,7 @@ export const ChatMessage = React.memo(
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && ( <> -

+

Steps

@@ -364,7 +364,7 @@ export const ChatMessage = React.memo( )} {subAgent.output && ( <> -

+

Output

- + {isEditingMode && file === null ? ( ( }, blockquote({ children }: { children?: React.ReactNode }) { return ( -
+
{children}
); diff --git a/src/app/components/MemoryPanel.tsx b/src/app/components/MemoryPanel.tsx new file mode 100644 index 0000000..d5e86f8 --- /dev/null +++ b/src/app/components/MemoryPanel.tsx @@ -0,0 +1,700 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowLeft, + BrainCircuit, + Check, + Copy, + Eye, + FileText, + Loader2, + Pencil, + Plus, + RotateCw, + Save, + Trash2, +} from "lucide-react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { MarkdownContent } from "@/app/components/MarkdownContent"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface MemoryEntry { + path: string; + size: number; + mtime: number; + editable: boolean; +} + +interface MemoryListing { + dir: string; + exists: boolean; + entries: MemoryEntry[]; + truncated: boolean; +} + +interface MemoryFile { + path: string; + content: string; + size: number; + mtime: number; +} + +const LANGUAGE_MAP: Record = { + json: "json", + yaml: "yaml", + yml: "yaml", + csv: "text", + tsv: "text", + log: "text", + txt: "text", + tex: "latex", + bib: "latex", + rst: "markdown", +}; + +// Friendly, ordered groups derived from a memory file's relative path. +const GROUP_ORDER = [ + "Core memory", + "Profile", + "Projects", + "Evolution reports", +] as const; + +function groupOf(path: string): string { + const parts = path.split("/"); + if (parts[0] === "profile") { + return parts[1] === "projects" ? "Projects" : "Profile"; + } + if (parts[0] === "evolution-reports") return "Evolution reports"; + if (parts.length === 1) return "Core memory"; + return parts[0]; +} + +/** A short subtitle shown under the file name (the containing dir, if any). */ +function subPathOf(path: string): string { + const i = path.lastIndexOf("/"); + return i >= 0 ? path.slice(0, i) : ""; +} + +function fileNameOf(path: string): string { + return path.split("/").pop() || path; +} + +function extOf(name: string): string { + const i = name.lastIndexOf("."); + return i >= 0 ? name.slice(i + 1).toLowerCase() : ""; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatTime(ms: number): string { + if (!ms) return ""; + try { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(new Date(ms)); + } catch { + return ""; + } +} + +export function MemoryPanel() { + const [listing, setListing] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [selected, setSelected] = useState(null); + const [file, setFile] = useState(null); + const [fileLoading, setFileLoading] = useState(false); + const [fileError, setFileError] = useState(null); + + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(false); + + const [newOpen, setNewOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [newBusy, setNewBusy] = useState(false); + const [newError, setNewError] = useState(null); + + const didAutoSelect = useRef(false); + // Monotonic id so a slow file fetch can't overwrite a newer selection. + const reqRef = useRef(0); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/memory"); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to load memory."); + setListing(data as MemoryListing); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load memory."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Group + sort the file list for the sidebar. + const groups = useMemo(() => { + const entries = listing?.entries ?? []; + const byGroup = new Map(); + for (const e of entries) { + const g = groupOf(e.path); + (byGroup.get(g) ?? byGroup.set(g, []).get(g)!).push(e); + } + const names = [...byGroup.keys()].sort((a, b) => { + const ia = GROUP_ORDER.indexOf(a as (typeof GROUP_ORDER)[number]); + const ib = GROUP_ORDER.indexOf(b as (typeof GROUP_ORDER)[number]); + if (ia !== -1 || ib !== -1) { + return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); + } + return a.localeCompare(b); + }); + return names.map((name) => ({ + name, + files: byGroup.get(name)!.sort((a, b) => a.path.localeCompare(b.path)), + })); + }, [listing]); + + const openFile = useCallback(async (path: string) => { + const reqId = ++reqRef.current; + setSelected(path); + setEditing(false); + setFile(null); + setFileError(null); + setFileLoading(true); + try { + const res = await fetch(`/api/memory?path=${encodeURIComponent(path)}`); + const data = await res.json(); + // A newer openFile started while this was in flight — drop the stale result. + if (reqId !== reqRef.current) return; + if (!res.ok) throw new Error(data.error || "Failed to open file."); + setFile(data as MemoryFile); + } catch (e) { + if (reqId !== reqRef.current) return; + setFileError(e instanceof Error ? e.message : "Failed to open file."); + } finally { + if (reqId === reqRef.current) setFileLoading(false); + } + }, []); + + // Auto-open the first file once, so the panel isn't empty on first view. + useEffect(() => { + if (didAutoSelect.current || selected) return; + const first = groups[0]?.files[0]; + if (first) { + didAutoSelect.current = true; + openFile(first.path); + } + }, [groups, selected, openFile]); + + const startEdit = () => { + if (!file) return; + setDraft(file.content); + setEditing(true); + }; + + const save = async () => { + if (!selected) return; + const reqId = reqRef.current; // detect a file switch during the await + setSaving(true); + setFileError(null); + try { + const res = await fetch("/api/memory", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selected, content: draft }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to save."); + // If the user switched files mid-save, don't clobber the now-current + // file's view (the save itself still persisted to disk). + if (reqId === reqRef.current) { + setFile(data as MemoryFile); + setEditing(false); + } + // Reflect the new size/mtime in the list. + load(); + } catch (e) { + if (reqId === reqRef.current) { + setFileError(e instanceof Error ? e.message : "Failed to save."); + } + } finally { + setSaving(false); + } + }; + + const remove = async () => { + if (!selected) return; + if ( + !window.confirm(`Delete "${fileNameOf(selected)}"? This can't be undone.`) + ) + return; + const reqId = reqRef.current; // detect a file switch during the await + try { + const res = await fetch( + `/api/memory?path=${encodeURIComponent(selected)}`, + { method: "DELETE" } + ); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.error || "Failed to delete."); + } + // Only reset the view if the user is still on the file they deleted. + if (reqId === reqRef.current) { + setSelected(null); + setFile(null); + setEditing(false); + } + load(); + } catch (e) { + if (reqId === reqRef.current) { + setFileError(e instanceof Error ? e.message : "Failed to delete."); + } + } + }; + + const copy = async () => { + if (!file) return; + try { + await navigator.clipboard.writeText(file.content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // clipboard may be unavailable; ignore + } + }; + + const createNew = async () => { + let name = newName.trim(); + if (!name) { + setNewError("Enter a file name."); + return; + } + if (!/\.[A-Za-z0-9]+$/.test(name)) name += ".md"; + setNewBusy(true); + setNewError(null); + try { + const res = await fetch("/api/memory", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: name, + content: `# ${fileNameOf(name).replace(/\.[^.]+$/, "")}\n\n`, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to create file."); + setNewOpen(false); + setNewName(""); + await load(); + await openFile((data as MemoryFile).path); + setDraft((data as MemoryFile).content); + setEditing(true); + } catch (e) { + setNewError(e instanceof Error ? e.message : "Failed to create file."); + } finally { + setNewBusy(false); + } + }; + + // Unsaved-edit guard: confirm before navigating away from dirty edits. + const dirty = editing && file != null && draft !== file.content; + const confirmDiscard = () => + !dirty || window.confirm("Discard your unsaved changes?"); + + const ext = selected ? extOf(fileNameOf(selected)) : ""; + const isMarkdown = ext === "md" || ext === "markdown"; + + return ( +
+
+
+
+
+

+ EvoScientist's long-term memory — what it knows about you, your + research taste, and lessons from past work. View or edit it + directly. +

+
+
+ + +
+
+ + {error ? ( +
+

{error}

+
+ ) : loading && !listing ? ( +
+
+ ) : listing && !listing.exists ? ( + + ) : listing && listing.entries.length === 0 ? ( + + ) : ( +
+ {/* File list */} + + + {/* Viewer / editor */} +
+ {!selected ? ( +
+

+ Select a memory file to view or edit. +

+
+ ) : ( + <> +
+ +
+

+ {fileNameOf(selected)} +

+ {file && ( +

+ {subPathOf(selected) && `${subPathOf(selected)} · `} + {formatBytes(file.size)} + {file.mtime ? ` · ${formatTime(file.mtime)}` : ""} +

+ )} +
+
+ {!editing ? ( + <> + + + + + ) : ( + <> + + + + )} +
+
+ + {fileError && ( +

+ {fileError} +

+ )} + +
+ {fileLoading ? ( +
+ +
+ ) : editing ? ( +