mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
feat(memory-graph): Milestone 3 polish — legend, isolate, keyboard nav
- Collapsible legend: click "Legend" to expand/collapse (caret rotates, pointer-events re-enabled now that the legend has something clickable). - "Isolate connected component" detail-panel action: a pure _componentNodeIds() BFS (undirected, no DOM dependency) feeds into _applyFilters() alongside the existing category filter, with a toggle button and a banner reporting how many memories are shown. Clears on background click and on every graph reload. - Keyboard shortcuts: `f` focuses the search box; arrow keys cycle selection through the currently visible (filter-respecting) nodes and re-center the camera. Both guarded against firing while typing or while the modal is minimized. - MODULE_SUMMARY.md: documented the memoryGraph.js module.
This commit is contained in:
parent
ee0db74064
commit
08b24b8499
3 changed files with 127 additions and 17 deletions
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div class="memory-graph-canvas-wrap" style="position:relative;flex:1;min-width:0;">
|
||||
<div class="memory-graph-canvas" id="memory-graph-canvas"></div>
|
||||
<div class="memory-graph-demo-banner hidden" id="memory-graph-demo-banner">Showing demo data — add memories to see your real graph</div>
|
||||
<div class="memory-graph-demo-banner hidden" id="memory-graph-isolate-banner"></div>
|
||||
<div class="memory-graph-legend" id="memory-graph-legend">
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line"></span><span>similarity</span></div>
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line dashed"></span><span>same session</span></div>
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line" style="border-top-color:var(--red);"></span><span>manual link</span></div>
|
||||
<div class="memory-graph-legend-header" id="memory-graph-legend-toggle">
|
||||
<span>Legend</span><span class="memory-graph-legend-caret">▾</span>
|
||||
</div>
|
||||
<div class="memory-graph-legend-body">
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line"></span><span>similarity</span></div>
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line dashed"></span><span>same session</span></div>
|
||||
<div class="memory-graph-legend-row"><span class="memory-graph-legend-line" style="border-top-color:var(--red);"></span><span>manual link</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="memory-graph-detail-panel hidden" id="memory-graph-detail">
|
||||
|
|
@ -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() {
|
|||
<button type="button" data-action="pin">${node.pinned ? 'Unpin' : 'Pin'}</button>
|
||||
<button type="button" data-action="edit">Edit</button>
|
||||
<button type="button" data-action="link">Start link</button>
|
||||
<button type="button" data-action="isolate">${_isolateRootId === node.id ? 'Show all' : 'Isolate'}</button>
|
||||
<button type="button" class="danger" data-action="delete">Delete</button>
|
||||
</div>
|
||||
<div class="memory-graph-detail-links">
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue