Files
NexusOS/interface/web/src/Memory.jsx
T
2026-07-11 07:25:14 -05:00

498 lines
19 KiB
React
Executable File

import { useEffect, useState, useCallback } from "react";
import { API_BASE } from "./config";
const DEFAULT_SECTIONS = ["Instructions", "Identity", "Career", "Projects", "Preferences"];
function groupBySection(items) {
const map = {};
for (const item of items) {
const sec = item.section || "General";
if (!map[sec]) map[sec] = [];
map[sec].push(item);
}
return map;
}
function indentLevelOf(text) {
const m = text.match(/^( *)/);
return Math.floor((m ? m[0].length : 0) / 2);
}
// Collapse a section's items into top-level "groups": each group is a parent
// (indent 0) plus its consecutive indented children.
function computeGroups(sectionItems) {
const groups = [];
const groupIndexById = {};
for (const item of sectionItems) {
const lvl = indentLevelOf(item.text);
if (lvl === 0 || groups.length === 0) {
groups.push([item]);
} else {
groups[groups.length - 1].push(item);
}
groupIndexById[item.id] = groups.length - 1;
}
return { groups, groupIndexById };
}
export function Memory() {
const [items, setItems] = useState([]);
const [editing, setEditing] = useState(null); // { id, section, text } or null
const [adding, setAdding] = useState(null); // { section } for inline new-item form
const [newSection, setNewSection] = useState("");
const [showNewSection, setShowNewSection] = useState(false);
const [message, setMessage] = useState("");
const [draggingId, setDraggingId] = useState(null);
const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" }
const loadItems = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/memory`);
const data = res.ok ? await res.json() : { items: [] };
setItems(data.items || []);
} catch {
setItems([]);
}
}, []);
useEffect(() => { loadItems(); }, [loadItems]);
const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); };
const saveNew = async (section, text) => {
// Split on newlines so a pasted list becomes one memory per line (leading
// spaces kept for nesting). Direct write — no LLM curator, saves every line.
const lines = text.split("\n").map(l => l.replace(/\s+$/, "")).filter(l => l.trim());
if (!lines.length) return;
let ok = 0;
for (const line of lines) {
const res = await fetch(`${API_BASE}/memory`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section, text: line }),
});
if (res.ok) ok++;
}
if (ok) { loadItems(); setAdding(null); flash(ok === 1 ? "Saved." : `Saved ${ok} items.`); }
else flash("Save failed.");
};
const saveEdit = async (id, section, text) => {
const cleaned = text.replace(/\s+$/, "");
if (!cleaned.trim()) return;
const res = await fetch(`${API_BASE}/memory/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section, text: cleaned }),
});
if (res.ok) { loadItems(); setEditing(null); flash("Updated."); }
else flash("Update failed.");
};
const deleteItem = async (id) => {
if (!window.confirm("Delete this item?")) return;
const res = await fetch(`${API_BASE}/memory/${id}`, { method: "DELETE" });
if (res.ok) { loadItems(); flash("Deleted."); }
else flash("Delete failed.");
};
const reorderSection = async (section, ids) => {
try {
const res = await fetch(`${API_BASE}/memory/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section, ids }),
});
const result = await res.json();
if (!res.ok || result.ok === false) {
throw new Error("Memory reorder failed");
}
loadItems();
} catch (err) {
setMessage(err.message);
}
};
const moveItem = (id, direction) => {
const item = items.find(i => i.id === id);
if (!item) return;
const section = item.section || "General";
const sectionItems = items.filter(i => (i.section || "General") === section);
const { groups, groupIndexById } = computeGroups(sectionItems);
const lvl = indentLevelOf(item.text);
if (lvl === 0) {
// Swap whole group with previous/next group
const gIdx = groupIndexById[id];
const tIdx = direction === "up" ? gIdx - 1 : gIdx + 1;
if (tIdx < 0 || tIdx >= groups.length) return;
const newGroups = [...groups];
[newGroups[gIdx], newGroups[tIdx]] = [newGroups[tIdx], newGroups[gIdx]];
reorderSection(section, newGroups.flat().map(i => i.id));
} else {
// Swap child with previous/next sibling within same group; never past the parent
const gIdx = groupIndexById[id];
const group = groups[gIdx];
const localIdx = group.findIndex(i => i.id === id);
const tLocal = direction === "up" ? localIdx - 1 : localIdx + 1;
if (tLocal <= 0 || tLocal >= group.length) return; // tLocal === 0 is the parent
const newGroup = [...group];
[newGroup[localIdx], newGroup[tLocal]] = [newGroup[tLocal], newGroup[localIdx]];
const newGroups = [...groups];
newGroups[gIdx] = newGroup;
reorderSection(section, newGroups.flat().map(i => i.id));
}
};
const onItemDragStart = (e, item) => {
setDraggingId(item.id);
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", item.id); } catch { /* some browsers restrict */ }
};
const onItemDragOver = (e, targetItem, section) => {
if (!draggingId || draggingId === targetItem.id) return;
const dragged = items.find(i => i.id === draggingId);
if (!dragged || (dragged.section || "General") !== section) return;
const sectionItems = items.filter(i => (i.section || "General") === section);
const { groups, groupIndexById } = computeGroups(sectionItems);
const draggedLevel = indentLevelOf(dragged.text);
const targetGroupIdx = groupIndexById[targetItem.id];
const draggedGroupIdx = groupIndexById[dragged.id];
const rect = e.currentTarget.getBoundingClientRect();
const inUpperHalf = e.clientY < rect.top + rect.height / 2;
if (draggedLevel === 0) {
// Top-level drag: snap to group boundaries; ignore hover over own group
if (targetGroupIdx === draggedGroupIdx) {
if (dragOver) setDragOver(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const targetGroup = groups[targetGroupIdx];
const isTargetParent = targetItem.id === targetGroup[0].id;
const position = (isTargetParent && inUpperHalf) ? "before" : "after";
if (dragOver?.kind !== "group" || dragOver?.section !== section || dragOver?.groupIdx !== targetGroupIdx || dragOver?.position !== position) {
setDragOver({ kind: "group", section, groupIdx: targetGroupIdx, position });
}
} else {
// Child drag: confined to its own parent group
if (targetGroupIdx !== draggedGroupIdx) {
if (dragOver) setDragOver(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const targetGroup = groups[targetGroupIdx];
const isTargetParent = targetItem.id === targetGroup[0].id;
let position = inUpperHalf ? "before" : "after";
if (isTargetParent) position = "after"; // child can't go before its parent
if (dragOver?.kind !== "item" || dragOver?.section !== section || dragOver?.id !== targetItem.id || dragOver?.position !== position) {
setDragOver({ kind: "item", section, id: targetItem.id, position });
}
}
};
const onItemDrop = (e, targetItem, section) => {
e.preventDefault();
const dragged = items.find(i => i.id === draggingId);
if (!dragged || dragged.id === targetItem.id || (dragged.section || "General") !== section || !dragOver) {
setDraggingId(null);
setDragOver(null);
return;
}
const sectionItems = items.filter(i => (i.section || "General") === section);
const { groups, groupIndexById } = computeGroups(sectionItems);
const draggedLevel = indentLevelOf(dragged.text);
let block, anchorId, after;
if (draggedLevel === 0 && dragOver.kind === "group") {
block = groups[groupIndexById[dragged.id]];
const targetGroup = groups[dragOver.groupIdx];
anchorId = dragOver.position === "before" ? targetGroup[0].id : targetGroup[targetGroup.length - 1].id;
after = dragOver.position === "after";
} else if (draggedLevel > 0 && dragOver.kind === "item") {
block = [dragged];
anchorId = dragOver.id;
after = dragOver.position === "after";
} else {
setDraggingId(null);
setDragOver(null);
return;
}
const blockIds = new Set(block.map(b => b.id));
const remainder = sectionItems.filter(i => !blockIds.has(i.id));
let insertIdx = remainder.findIndex(i => i.id === anchorId);
if (insertIdx < 0) {
setDraggingId(null);
setDragOver(null);
return;
}
if (after) insertIdx += 1;
const newOrder = [...remainder.slice(0, insertIdx), ...block, ...remainder.slice(insertIdx)];
setDraggingId(null);
setDragOver(null);
reorderSection(section, newOrder.map(i => i.id));
};
const onItemDragEnd = () => {
setDraggingId(null);
setDragOver(null);
};
const addSection = () => {
const sec = newSection.trim();
if (!sec) return;
// Create a placeholder item to establish the section, then let user fill it
setAdding({ section: sec });
setNewSection("");
setShowNewSection(false);
};
const grouped = groupBySection(items);
const sections = Object.keys(grouped);
const inputStyle = {
background: "#1a1a1a",
color: "#eee",
border: "1px solid #444",
borderRadius: "6px",
padding: "0.4rem 0.6rem",
fontSize: "0.9rem",
width: "100%",
boxSizing: "border-box",
};
const btnStyle = (color = "#007acc") => ({
padding: "0.3rem 0.7rem",
background: color,
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "0.8rem",
});
return (
<div style={{ flexGrow: 1 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<div>
<h2 style={{ margin: 0, fontSize: "1.1rem" }}>Memory</h2>
<p style={{ margin: "0.25rem 0 0", color: "#888", fontSize: "0.85rem" }}>
Facts injected into every conversation. Format mirrors your claude.md.
</p>
</div>
<button onClick={() => setShowNewSection(s => !s)} style={btnStyle()}>
+ New Section
</button>
</div>
{showNewSection && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.25rem" }}>
<input
autoFocus
type="text"
placeholder="Section name (e.g. Health, Finance)"
value={newSection}
onChange={e => setNewSection(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") addSection(); if (e.key === "Escape") setShowNewSection(false); }}
style={{ ...inputStyle, maxWidth: "320px" }}
/>
<button onClick={addSection} style={btnStyle()}>Add</button>
<button onClick={() => setShowNewSection(false)} style={btnStyle("#444")}>Cancel</button>
</div>
)}
{sections.length === 0 && !adding && (
<div style={{ color: "#666", fontSize: "0.9rem", marginTop: "2rem" }}>
No memory yet. Add a section to get started.
</div>
)}
{/* Render sections in claude.md style */}
{[...sections, ...(adding && !sections.includes(adding.section) ? [adding.section] : [])].map(section => (
<div key={section} style={{ marginBottom: "2rem" }}>
{/* ## Section Header */}
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<h3 style={{ margin: 0, fontSize: "1rem", color: "#4db8ff" }}>## {section}</h3>
<button
onClick={() => setAdding({ section })}
style={{ ...btnStyle("#2a3a2a"), border: "1px solid #3a5a3a", color: "#7dcc7d", fontSize: "0.75rem" }}
>
+ Add item
</button>
</div>
{/* Bullet items */}
{(() => {
const sectionItems = grouped[section] || [];
const { groups, groupIndexById } = computeGroups(sectionItems);
return (
<div style={{ paddingLeft: "1rem", borderLeft: "2px solid #222" }}>
{sectionItems.map((item) => {
const indentMatch = item.text.match(/^( *)(.*)$/s);
const indentLevel = indentMatch ? Math.floor(indentMatch[1].length / 2) : 0;
const displayText = indentMatch ? indentMatch[2] : item.text;
const gIdx = groupIndexById[item.id];
const group = groups[gIdx];
const localIdx = group.findIndex(i => i.id === item.id);
const isParentRow = localIdx === 0;
const isLastInGroup = localIdx === group.length - 1;
const arrowUpDisabled = indentLevel === 0 ? gIdx === 0 : localIdx <= 1;
const arrowDownDisabled = indentLevel === 0 ? gIdx === groups.length - 1 : isLastInGroup;
const isDragging = draggingId === item.id;
let showLineBefore = false, showLineAfter = false;
if (dragOver && dragOver.section === section) {
if (dragOver.kind === "group" && dragOver.groupIdx === gIdx) {
if (dragOver.position === "before" && isParentRow) showLineBefore = true;
if (dragOver.position === "after" && isLastInGroup) showLineAfter = true;
} else if (dragOver.kind === "item" && dragOver.id === item.id) {
if (dragOver.position === "before") showLineBefore = true;
else showLineAfter = true;
}
}
return (
<div
key={item.id}
draggable={!editing}
onDragStart={(e) => onItemDragStart(e, item)}
onDragOver={(e) => onItemDragOver(e, item, section)}
onDrop={(e) => onItemDrop(e, item, section)}
onDragEnd={onItemDragEnd}
style={{
marginBottom: "0.4rem",
paddingLeft: `${indentLevel * 1.5}rem`,
borderTop: showLineBefore ? "2px solid #4db8ff" : "2px solid transparent",
borderBottom: showLineAfter ? "2px solid #4db8ff" : "2px solid transparent",
opacity: isDragging ? 0.4 : 1,
}}
>
{editing?.id === item.id ? (
<EditRow
initial={editing.text}
onSave={text => saveEdit(item.id, section, text)}
onCancel={() => setEditing(null)}
inputStyle={inputStyle}
btnStyle={btnStyle}
/>
) : (
<div
onDoubleClick={() => setEditing({ id: item.id, section, text: item.text })}
title="Drag to reorder · double-click to edit"
style={{ display: "flex", alignItems: "flex-start", gap: "0.5rem", cursor: "grab" }}
>
<span style={{ color: "#aaa", marginTop: "0.1rem", flexShrink: 0 }}>-</span>
<span style={{ color: "#ddd", fontSize: "0.9rem", flexGrow: 1 }}>{displayText}</span>
<div style={{ display: "flex", gap: "0.3rem", flexShrink: 0, opacity: 0.4 }}
onMouseEnter={e => e.currentTarget.style.opacity = 1}
onMouseLeave={e => e.currentTarget.style.opacity = 0.4}
>
<button
onClick={() => moveItem(item.id, "up")}
disabled={arrowUpDisabled}
title="Move up"
style={{ ...btnStyle("#333"), fontSize: "0.72rem", padding: "0.2rem 0.4rem", opacity: arrowUpDisabled ? 0.3 : 1, cursor: arrowUpDisabled ? "default" : "pointer" }}
></button>
<button
onClick={() => moveItem(item.id, "down")}
disabled={arrowDownDisabled}
title="Move down"
style={{ ...btnStyle("#333"), fontSize: "0.72rem", padding: "0.2rem 0.4rem", opacity: arrowDownDisabled ? 0.3 : 1, cursor: arrowDownDisabled ? "default" : "pointer" }}
></button>
<button
onClick={() => deleteItem(item.id)}
style={{ ...btnStyle("#5a1a1a"), fontSize: "0.72rem", padding: "0.2rem 0.5rem" }}
>del</button>
</div>
</div>
)}
</div>
);
})}
{/* Inline add form */}
{adding?.section === section && (
<AddRow
onSave={text => saveNew(section, text)}
onCancel={() => setAdding(null)}
inputStyle={inputStyle}
btnStyle={btnStyle}
/>
)}
</div>
);
})()}
</div>
))}
{message && (
<div style={{ position: "fixed", bottom: "1.5rem", right: "1.5rem", background: "#1a2e1a", border: "1px solid #3a5a3a", color: "#7dcc7d", padding: "0.6rem 1rem", borderRadius: "8px", fontSize: "0.85rem" }}>
{message}
</div>
)}
</div>
);
}
function handleIndentKey(e, setText) {
if (e.key !== "Tab") return false;
e.preventDefault();
if (e.shiftKey) {
setText(t => t.startsWith(" ") ? t.slice(2) : t);
} else {
setText(t => " " + t);
}
return true;
}
function AddRow({ onSave, onCancel, inputStyle, btnStyle }) {
const [text, setText] = useState("");
return (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.4rem" }}>
<span style={{ color: "#555", flexShrink: 0 }}>-</span>
<textarea
autoFocus
rows={1}
placeholder="New item… (paste multiple lines to add several at once)"
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (handleIndentKey(e, setText)) return;
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSave(text); }
if (e.key === "Escape") onCancel();
}}
style={{ ...inputStyle, resize: "vertical", fontFamily: "inherit", minHeight: "2.1rem" }}
/>
<button onClick={() => onSave(text)} style={btnStyle()}>Save</button>
<button onClick={onCancel} style={btnStyle("#444")}></button>
</div>
);
}
function EditRow({ initial, onSave, onCancel, inputStyle, btnStyle }) {
const [text, setText] = useState(initial);
return (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<span style={{ color: "#555", flexShrink: 0 }}>-</span>
<input
autoFocus
type="text"
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (handleIndentKey(e, setText)) return;
if (e.key === "Enter") onSave(text);
if (e.key === "Escape") onCancel();
}}
style={{ ...inputStyle }}
/>
<button onClick={() => onSave(text)} style={btnStyle()}>Save</button>
<button onClick={onCancel} style={btnStyle("#444")}></button>
</div>
);
}