diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md
index df5b0cb33..92d3b9e55 100644
--- a/static/js/MODULE_SUMMARY.md
+++ b/static/js/MODULE_SUMMARY.md
@@ -106,6 +106,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| Module | Responsibility |
|---|---|
| **`memory.js`** | AI memory CRUD, search/filter UI, memory extraction, count badge. |
+| **`memoryGraph.js`** | Memory Graph View (beta): Cytoscape.js-based interactive graph over a user's memories and their derived (similarity/session) and manual relationships. Lazily vendors `static/lib/cytoscape.min.js`, follows `calendar.js`'s self-registering modal pattern. Node click + neighborhood highlight, arrow-key node navigation, `f`-to-focus search, category/similarity filtering, collapsible legend, connected-component isolation, link-mode manual relationship editing, detail panel (pin/edit/delete/link management), demo-data fallback. Backed by `GET /api/memory/graph` + `POST`/`DELETE /api/memory/{id}/links` (`routes/memory/memory_graph_routes.py`). |
| **`rag.js`** | Personal document RAG: load documents, add directories/files, show included paths. |
| **`group.js`** | Group-chat UI and model orchestration. |
diff --git a/static/js/memoryGraph.js b/static/js/memoryGraph.js
index 211862b77..7beb1bae2 100644
--- a/static/js/memoryGraph.js
+++ b/static/js/memoryGraph.js
@@ -67,7 +67,8 @@ let _minSimilarity = 0.75;
let _linkMode = false;
let _linkSourceId = null;
let _selectedId = null;
-let _escHandler = null;
+let _isolateRootId = null; // set = only this node's connected component is shown
+let _keyHandler = null;
let _resizeWired = false;
// ---- lazy-load Cytoscape, mirroring documentLibrary.js's ensureXLSX/ensureMammoth ----
@@ -117,10 +118,16 @@ function _getModal() {
Showing demo data — add memories to see your real graph
+
-
similarity
-
same session
-
manual link
+
+
+
similarity
+
same session
+
manual link
+
@@ -163,6 +170,12 @@ function _wireToolbar() {
}
const linkBtn = document.getElementById('memory-graph-link-mode-btn');
if (linkBtn) linkBtn.addEventListener('click', () => _setLinkMode(!_linkMode));
+ const legendToggle = document.getElementById('memory-graph-legend-toggle');
+ if (legendToggle) {
+ legendToggle.addEventListener('click', () => {
+ document.getElementById('memory-graph-legend')?.classList.toggle('collapsed');
+ });
+ }
}
function _wireResize() {
@@ -345,8 +358,10 @@ function _renderGraph() {
wheelSensitivity: 0.2,
});
_wireCyEvents();
+ _isolateRootId = null;
_applyFilters();
_renderDemoBanner();
+ _renderIsolateBanner();
_selectedId = null;
_renderDetailPanel();
}
@@ -383,12 +398,40 @@ function _renderCategoryChips() {
});
}
+// Pure BFS over a graph's edges (undirected) — the set of node ids reachable
+// from rootId, including rootId itself. Used by the "Isolate" detail-panel
+// action to show only a node's connected component. No DOM/Cytoscape
+// dependency, so this is unit-testable in isolation (see memoryGraph tests).
+function _componentNodeIds(graph, rootId) {
+ const adjacency = new Map();
+ (graph.nodes || []).forEach(n => adjacency.set(n.id, new Set()));
+ (graph.edges || []).forEach(e => {
+ if (!adjacency.has(e.source) || !adjacency.has(e.target)) return;
+ adjacency.get(e.source).add(e.target);
+ adjacency.get(e.target).add(e.source);
+ });
+ const seen = new Set();
+ if (!adjacency.has(rootId)) return seen;
+ const stack = [rootId];
+ while (stack.length) {
+ const id = stack.pop();
+ if (seen.has(id)) continue;
+ seen.add(id);
+ for (const neighbor of adjacency.get(id) || []) {
+ if (!seen.has(neighbor)) stack.push(neighbor);
+ }
+ }
+ return seen;
+}
+
function _applyFilters() {
if (!_cy) return;
+ const isolateIds = _isolateRootId ? _componentNodeIds(_graph, _isolateRootId) : null;
_cy.batch(() => {
_cy.nodes().forEach(n => {
- const visible = !_activeCategory || n.data('category') === _activeCategory;
- n.style('display', visible ? 'element' : 'none');
+ const categoryOk = !_activeCategory || n.data('category') === _activeCategory;
+ const isolateOk = !isolateIds || isolateIds.has(n.id());
+ n.style('display', (categoryOk && isolateOk) ? 'element' : 'none');
});
_cy.edges().forEach(e => {
const src = _cy.getElementById(e.data('source'));
@@ -401,6 +444,22 @@ function _applyFilters() {
_applySearch();
}
+function _renderIsolateBanner() {
+ const banner = document.getElementById('memory-graph-isolate-banner');
+ if (!banner) return;
+ if (!_isolateRootId) { banner.classList.add('hidden'); return; }
+ const count = _componentNodeIds(_graph, _isolateRootId).size;
+ banner.textContent = `Isolated — showing ${count} connected ${count === 1 ? 'memory' : 'memories'}`;
+ banner.classList.remove('hidden');
+}
+
+function _toggleIsolate(id) {
+ _isolateRootId = (_isolateRootId === id) ? null : id;
+ _applyFilters();
+ _renderIsolateBanner();
+ _renderDetailPanel();
+}
+
function _applySearch() {
if (!_cy) return;
_cy.nodes().removeClass('mg-search-match');
@@ -454,6 +513,11 @@ function _selectNode(id) {
function _clearSelection() {
_selectedId = null;
+ if (_isolateRootId) {
+ _isolateRootId = null;
+ _applyFilters();
+ _renderIsolateBanner();
+ }
if (_cy) _cy.elements().removeClass('mg-highlighted mg-dimmed');
_renderDetailPanel();
}
@@ -539,6 +603,7 @@ function _renderDetailPanel() {
+
@@ -569,6 +634,7 @@ function _renderDetailPanel() {
_cy.getElementById(node.id).addClass('mg-link-source');
uiModule.showToast?.('Click another memory to connect it', 2500);
});
+ panel.querySelector('[data-action="isolate"]')?.addEventListener('click', () => _toggleIsolate(node.id));
panel.querySelector('[data-action="delete"]')?.addEventListener('click', () => _actionDelete(node));
panel.querySelectorAll('.memory-graph-link-row-remove').forEach(el => {
el.addEventListener('click', (e) => {
@@ -705,6 +771,23 @@ async function _actionRemoveLink(sourceId, targetId) {
}
}
+// ---- keyboard navigation ----
+function _visibleNodeIds() {
+ if (!_cy) return [];
+ return _cy.nodes().filter(n => n.style('display') !== 'none').map(n => n.id());
+}
+
+function _navigateNodes(delta) {
+ const ids = _visibleNodeIds();
+ if (!ids.length) return;
+ const curIdx = _selectedId ? ids.indexOf(_selectedId) : -1;
+ const nextIdx = curIdx === -1 ? 0 : (curIdx + delta + ids.length) % ids.length;
+ const id = ids[nextIdx];
+ _selectNode(id);
+ const node = _cy.getElementById(id);
+ if (node && node.nonempty()) _cy.animate({ center: { eles: node } }, { duration: 150 });
+}
+
// ---- open / close ----
export function isMemoryGraphOpen() {
if (Modals.isMinimized('memory-graph-modal')) return false;
@@ -740,19 +823,32 @@ export function openMemoryGraph() {
const btn = document.getElementById('tool-memory-graph-btn');
if (btn) btn.classList.add('active');
- _escHandler = (e) => {
- if (e.key !== 'Escape') return;
+ _keyHandler = (e) => {
+ if (Modals.isMinimized('memory-graph-modal')) return;
const active = document.activeElement;
- if (active && active.id === 'memory-graph-search' && active.value) {
- active.value = '';
- _searchTerm = '';
- _applySearch();
+ const typing = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
+
+ if (e.key === 'Escape') {
+ if (active && active.id === 'memory-graph-search' && active.value) {
+ active.value = '';
+ _searchTerm = '';
+ _applySearch();
+ return;
+ }
+ if (_linkMode) { _setLinkMode(false); return; }
+ closeMemoryGraph();
return;
}
- if (_linkMode) { _setLinkMode(false); return; }
- closeMemoryGraph();
+ if (typing) return; // don't hijack f/arrows while the user is typing anywhere in the modal
+ if (e.key === 'f' && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ e.preventDefault();
+ document.getElementById('memory-graph-search')?.focus();
+ return;
+ }
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); _navigateNodes(1); return; }
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); _navigateNodes(-1); return; }
};
- document.addEventListener('keydown', _escHandler);
+ document.addEventListener('keydown', _keyHandler);
_wireResize();
_loadGraph();
@@ -763,7 +859,7 @@ function _doCloseMemoryGraph() {
_open = false;
_setLinkMode(false);
if (_modal) { _modal.style.display = 'none'; _modal.classList.add('hidden'); }
- if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; }
+ if (_keyHandler) { document.removeEventListener('keydown', _keyHandler); _keyHandler = null; }
const btn = document.getElementById('tool-memory-graph-btn');
if (btn) btn.classList.remove('active');
}
diff --git a/static/style.css b/static/style.css
index b734171f5..e2a319f45 100644
--- a/static/style.css
+++ b/static/style.css
@@ -41358,9 +41358,22 @@ body.theme-frosted .modal {
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 9px;
- pointer-events: none;
z-index: 4;
}
+.memory-graph-legend-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
+ font-weight: 600;
+ color: var(--fg);
+ opacity: 0.85;
+}
+.memory-graph-legend-caret { font-size: 9px; transition: transform 0.15s; }
+.memory-graph-legend.collapsed .memory-graph-legend-caret { transform: rotate(-90deg); }
+.memory-graph-legend.collapsed .memory-graph-legend-body { display: none; }
+.memory-graph-legend-body { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
.memory-graph-legend-row { display: flex; align-items: center; gap: 5px; }
.memory-graph-legend-line { width: 14px; height: 0; border-top: 2px solid var(--fg); display: inline-block; }
.memory-graph-legend-line.dashed { border-top-style: dashed; }