5c2df30342
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
498 lines
19 KiB
React
Executable File
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>
|
|
);
|
|
}
|