mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
feat(memory): add Memory Graph View frontend (Milestone 2)
Cytoscape.js-based interactive graph over the memory system's derived edges (similarity/session/manual), following the repo's no-bundler vendoring and lazy-modal conventions. - static/js/memoryGraph.js: modal shell, lazy Cytoscape load, fetch + client-side filter/search, node/edge styling with dark/light theme support via CSS custom properties, node selection + neighborhood highlighting, link-mode manual relationship editing, detail panel (pin/edit/delete/link management), demo-data fallback. - static/lib/cytoscape.min.js: vendored 3.34.0 (MIT) via npm pack. - static/index.html, static/app.js, static/js/init.js: "Memory Graph" nav item (mirrors the Calendar entry), privilege gating (can_manage_memory), Customize-UI visibility entry, /memory-graph deep link. - static/style.css: .memory-graph-* block, appended only. Visually verified end-to-end against a real seeded dataset (render, node click + neighborhood highlight, detail panel, inline edit, search, category filters, similarity slider, link-mode create, theme switch) — fixed two bugs found in that pass along the way: the detail panel's `hidden` class was never removed on selection, and search left stale selection-highlight opacity masking matches.
This commit is contained in:
parent
4e422619e1
commit
3681be1834
6 changed files with 1080 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ import markdownModule from './js/markdown.js';
|
|||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import memoryGraphModule from './js/memoryGraph.js?v=20260729memorygraph1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
|
|
@ -1062,6 +1063,19 @@ function initializeEventListeners() {
|
|||
});
|
||||
}
|
||||
|
||||
// Memory Graph tool button
|
||||
const toolMemoryGraphBtn = el('tool-memory-graph-btn');
|
||||
if (toolMemoryGraphBtn) {
|
||||
toolMemoryGraphBtn.addEventListener('click', async () => {
|
||||
if (!memoryGraphModule) return;
|
||||
const Modals = await import('./js/modalManager.js');
|
||||
if (!Modals.toggle('memory-graph-modal')) {
|
||||
if (memoryGraphModule.isMemoryGraphOpen()) memoryGraphModule.closeMemoryGraph();
|
||||
else memoryGraphModule.openMemoryGraph();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calendar tool button
|
||||
const toolCalendarBtn = el('tool-calendar-btn');
|
||||
if (toolCalendarBtn) {
|
||||
|
|
@ -1212,6 +1226,7 @@ function initializeEventListeners() {
|
|||
setTimeout(_goFullscreen, 200);
|
||||
},
|
||||
'/memory': () => document.getElementById('tool-memory-btn')?.click(),
|
||||
'/memory-graph': () => document.getElementById('tool-memory-graph-btn')?.click(),
|
||||
'/gallery': () => document.getElementById('tool-gallery-btn')?.click(),
|
||||
'/tasks': () => document.getElementById('tool-tasks-btn')?.click(),
|
||||
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
||||
|
|
@ -2727,6 +2742,7 @@ function initializeEventListeners() {
|
|||
'tool-gallery': '#tool-gallery-btn',
|
||||
'tool-library': '#tool-library-btn',
|
||||
'tool-memory': '#tool-memory-btn',
|
||||
'tool-memory-graph': '#tool-memory-graph-btn',
|
||||
'tool-notes': '#tool-notes-btn',
|
||||
'tool-tasks': '#tool-tasks-btn',
|
||||
'tool-theme': '#tool-theme-btn',
|
||||
|
|
|
|||
|
|
@ -860,6 +860,23 @@
|
|||
</svg>
|
||||
<span class="grow">Brain</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-memory-graph-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink:0;opacity:0.5;">
|
||||
<circle cx="6" cy="6" r="2.4"/>
|
||||
<circle cx="18" cy="6" r="2.4"/>
|
||||
<circle cx="12" cy="13" r="2.4"/>
|
||||
<circle cx="6" cy="19" r="2.4"/>
|
||||
<circle cx="18" cy="19" r="2.4"/>
|
||||
<line x1="7.7" y1="7.3" x2="10.6" y2="11.5"/>
|
||||
<line x1="16.3" y1="7.3" x2="13.4" y2="11.5"/>
|
||||
<line x1="10.9" y1="14.8" x2="7.7" y2="17.7"/>
|
||||
<line x1="13.1" y1="14.8" x2="16.3" y2="17.7"/>
|
||||
<line x1="8.2" y1="6.2" x2="15.8" y2="6.2"/>
|
||||
</svg>
|
||||
<span class="grow">Memory Graph</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-calendar-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
|||
hideOn('#tool-research-btn, #research-toggle-btn', privs.can_use_research);
|
||||
// Memory & skills (rail/tool button only — UI/API entry).
|
||||
hideOn('#tool-memory-btn', privs.can_manage_memory);
|
||||
hideOn('#tool-memory-graph-btn', privs.can_manage_memory);
|
||||
// Agent mode toggle — force chat mode by hiding the Agent toggle button.
|
||||
if (privs.can_use_agent === false) {
|
||||
const _agent = document.getElementById('mode-agent-btn');
|
||||
|
|
|
|||
781
static/js/memoryGraph.js
Normal file
781
static/js/memoryGraph.js
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
// Memory Graph View (beta)
|
||||
// Interactive Cytoscape.js visualization of a user's own memories and their
|
||||
// derived (semantic similarity, same-session) and manual relationships.
|
||||
// Backed by GET /api/memory/graph and POST/DELETE /api/memory/{id}/links
|
||||
// (routes/memory/memory_graph_routes.py).
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
// Category → CSS custom-property name. Resolved to a concrete color at
|
||||
// render time via getComputedStyle so it tracks the active dark/light theme.
|
||||
const CATEGORY_VAR = {
|
||||
fact: '--fg',
|
||||
identity: '--hl-keyword',
|
||||
preference: '--warn',
|
||||
contact: '--color-accent',
|
||||
project: '--color-brand-blue',
|
||||
goal: '--accent-warm',
|
||||
task: '--green',
|
||||
};
|
||||
const CATEGORY_FALLBACK_VAR = '--color-muted-alt';
|
||||
const KNOWN_CATEGORIES = Object.keys(CATEGORY_VAR);
|
||||
|
||||
function _cssVar(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function _categoryColor(category) {
|
||||
const varName = CATEGORY_VAR[category] || CATEGORY_FALLBACK_VAR;
|
||||
return _cssVar(varName, '#888');
|
||||
}
|
||||
|
||||
// ---- placeholder data, shown only when the caller has zero real memories ----
|
||||
const DEMO_GRAPH = (() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const nodes = [
|
||||
{ id: 'demo-1', text: 'Works as a product designer at a small startup', category: 'identity', uses: 4, pinned: true, timestamp: now - 86400 * 30 },
|
||||
{ id: 'demo-2', text: 'Prefers dark roast coffee, no sugar', category: 'preference', uses: 2, pinned: false, timestamp: now - 86400 * 20 },
|
||||
{ id: 'demo-3', text: 'Working on a side project called "Lighthouse"', category: 'project', uses: 6, pinned: true, timestamp: now - 86400 * 14, session_id: 'demo-session' },
|
||||
{ id: 'demo-4', text: 'Wants to launch Lighthouse beta by end of quarter', category: 'goal', uses: 3, pinned: false, timestamp: now - 86400 * 10, session_id: 'demo-session' },
|
||||
{ id: 'demo-5', text: "Partner's birthday is on the 12th", category: 'fact', uses: 1, pinned: false, timestamp: now - 86400 * 6 },
|
||||
{ id: 'demo-6', text: 'Best reached by email rather than phone', category: 'contact', uses: 1, pinned: false, timestamp: now - 86400 * 2 },
|
||||
];
|
||||
const edges = [
|
||||
{ source: 'demo-3', target: 'demo-4', type: 'session', weight: 1 },
|
||||
{ source: 'demo-1', target: 'demo-3', type: 'manual', weight: 1 },
|
||||
{ source: 'demo-1', target: 'demo-2', type: 'similarity', weight: 0.81 },
|
||||
];
|
||||
return { nodes, edges, meta: { node_count: nodes.length, edge_count: edges.length, total_memories: nodes.length, truncated: false } };
|
||||
})();
|
||||
|
||||
// ---- module state ----
|
||||
let _modal = null;
|
||||
let _cy = null;
|
||||
let _open = false;
|
||||
let _isDemo = false;
|
||||
let _graph = { nodes: [], edges: [] };
|
||||
let _activeCategory = null; // null = show all categories
|
||||
let _searchTerm = '';
|
||||
let _minSimilarity = 0.75;
|
||||
let _linkMode = false;
|
||||
let _linkSourceId = null;
|
||||
let _selectedId = null;
|
||||
let _escHandler = null;
|
||||
let _resizeWired = false;
|
||||
|
||||
// ---- lazy-load Cytoscape, mirroring documentLibrary.js's ensureXLSX/ensureMammoth ----
|
||||
let _cytoscapeReady = null;
|
||||
function ensureCytoscape() {
|
||||
if (_cytoscapeReady) return _cytoscapeReady;
|
||||
if (window.cytoscape) return (_cytoscapeReady = Promise.resolve());
|
||||
_cytoscapeReady = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '/static/lib/cytoscape.min.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error('Failed to load Cytoscape library'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _cytoscapeReady;
|
||||
}
|
||||
|
||||
// ---- modal shell ----
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'memory-graph-modal';
|
||||
_modal.className = 'modal hidden';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content memory-graph-modal-content">
|
||||
<div class="modal-header">
|
||||
<h4>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px">
|
||||
<circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="12" cy="13" r="2.4"/><circle cx="6" cy="19" r="2.4"/><circle cx="18" cy="19" r="2.4"/>
|
||||
<line x1="7.7" y1="7.3" x2="10.6" y2="11.5"/><line x1="16.3" y1="7.3" x2="13.4" y2="11.5"/><line x1="10.9" y1="14.8" x2="7.7" y2="17.7"/><line x1="13.1" y1="14.8" x2="16.3" y2="17.7"/>
|
||||
</svg>Memory Graph <span style="font-size:10px;opacity:0.5;font-weight:400;">beta</span>
|
||||
</h4>
|
||||
<button class="close-btn" id="memory-graph-close">✖</button>
|
||||
</div>
|
||||
<div class="memory-graph-modal-body">
|
||||
<div class="memory-graph-toolbar">
|
||||
<input type="text" class="memory-graph-search" id="memory-graph-search" placeholder="Search memories…" />
|
||||
<div class="memory-graph-cat-chips" id="memory-graph-cat-chips"></div>
|
||||
<div class="memory-graph-slider-row">
|
||||
<span>min match</span>
|
||||
<input type="range" id="memory-graph-similarity" min="0.5" max="0.95" step="0.05" value="${_minSimilarity}" />
|
||||
</div>
|
||||
<button type="button" class="memory-graph-link-mode-btn" id="memory-graph-link-mode-btn" title="Click two memories to draw a relationship between them">Link mode</button>
|
||||
</div>
|
||||
<div class="memory-graph-main">
|
||||
<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-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>
|
||||
</div>
|
||||
<div class="memory-graph-detail-panel hidden" id="memory-graph-detail">
|
||||
<div class="memory-graph-detail-empty">Select a memory to see details.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#memory-graph-close').addEventListener('click', closeMemoryGraph);
|
||||
_modal.addEventListener('click', (e) => { if (e.target === _modal) closeMemoryGraph(); });
|
||||
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
if (content) {
|
||||
const obs = new ResizeObserver(() => { if (_cy) _cy.resize(); });
|
||||
obs.observe(content);
|
||||
}
|
||||
|
||||
_wireToolbar();
|
||||
return _modal;
|
||||
}
|
||||
|
||||
function _wireToolbar() {
|
||||
const search = document.getElementById('memory-graph-search');
|
||||
if (search) {
|
||||
let debounceTimer;
|
||||
search.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => { _searchTerm = search.value; _applySearch(); }, 220);
|
||||
});
|
||||
}
|
||||
const slider = document.getElementById('memory-graph-similarity');
|
||||
if (slider) {
|
||||
slider.addEventListener('input', () => {
|
||||
_minSimilarity = parseFloat(slider.value) || 0.75;
|
||||
_applyFilters();
|
||||
});
|
||||
}
|
||||
const linkBtn = document.getElementById('memory-graph-link-mode-btn');
|
||||
if (linkBtn) linkBtn.addEventListener('click', () => _setLinkMode(!_linkMode));
|
||||
}
|
||||
|
||||
function _wireResize() {
|
||||
if (_resizeWired) return;
|
||||
_resizeWired = true;
|
||||
window.addEventListener('resize', () => { if (_cy && _open) _cy.resize(); });
|
||||
}
|
||||
|
||||
// ---- data loading ----
|
||||
function _buildQuery() {
|
||||
const params = new URLSearchParams();
|
||||
// Over-fetch at a floor below the lowest UI slider position (0.5) and a
|
||||
// generous per-node edge cap; category/similarity filtering beyond that is
|
||||
// done client-side against this cached graph so chip/slider changes are
|
||||
// instant and don't re-hit the backend (see docs/memory-graph-design.md,
|
||||
// "Performance considerations").
|
||||
params.set('min_similarity', '0.5');
|
||||
params.set('max_edges_per_node', '8');
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function _fetchGraph() {
|
||||
const res = await fetch(`${API_BASE}/api/memory/graph?${_buildQuery()}`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`Memory graph request failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function _loadGraph() {
|
||||
_renderLoadingState();
|
||||
try {
|
||||
const data = await _fetchGraph();
|
||||
if (!data.nodes || !data.nodes.length) {
|
||||
_graph = DEMO_GRAPH;
|
||||
_isDemo = true;
|
||||
} else {
|
||||
_graph = data;
|
||||
_isDemo = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] load failed', err);
|
||||
_graph = DEMO_GRAPH;
|
||||
_isDemo = true;
|
||||
uiModule.showToast?.('Could not load your memory graph — showing demo data', 3000);
|
||||
}
|
||||
try {
|
||||
await ensureCytoscape();
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] cytoscape load failed', err);
|
||||
uiModule.showToast?.('Could not load the graph renderer', 3000);
|
||||
return;
|
||||
}
|
||||
_renderCategoryChips();
|
||||
_renderGraph();
|
||||
}
|
||||
|
||||
function _renderLoadingState() {
|
||||
const canvas = document.getElementById('memory-graph-canvas');
|
||||
if (!canvas) return;
|
||||
canvas.innerHTML = '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'memory-graph-loading';
|
||||
wrap.appendChild(spinnerModule.createLoadingRow('Loading memory graph…', 18));
|
||||
canvas.appendChild(wrap);
|
||||
}
|
||||
|
||||
// ---- cytoscape element/style construction ----
|
||||
function _nodeSize(n) {
|
||||
return Math.max(20, Math.min(60, 20 + (n.uses || 0) * 4));
|
||||
}
|
||||
|
||||
function _toElements(graph) {
|
||||
const nodeById = new Map(graph.nodes.map(n => [n.id, n]));
|
||||
const nodes = graph.nodes.map(n => ({
|
||||
data: {
|
||||
id: n.id,
|
||||
label: (n.text || '').length > 42 ? `${n.text.slice(0, 42)}…` : (n.text || ''),
|
||||
category: n.category || 'fact',
|
||||
color: _categoryColor(n.category || 'fact'),
|
||||
size: _nodeSize(n),
|
||||
pinned: !!n.pinned,
|
||||
},
|
||||
}));
|
||||
const edges = [];
|
||||
graph.edges.forEach((e, i) => {
|
||||
if (!nodeById.has(e.source) || !nodeById.has(e.target)) return;
|
||||
edges.push({
|
||||
data: {
|
||||
id: `e${i}-${e.source}-${e.target}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
type: e.type,
|
||||
weight: e.weight || 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function _cyStyle() {
|
||||
const fg = _cssVar('--fg', '#9cdef2');
|
||||
const bg = _cssVar('--bg', '#282c34');
|
||||
const border = _cssVar('--border', '#355a66');
|
||||
const accent = _cssVar('--accent', _cssVar('--red', '#e06c75'));
|
||||
return [
|
||||
{ selector: 'node', style: {
|
||||
'background-color': 'data(color)',
|
||||
'label': 'data(label)',
|
||||
'width': 'data(size)',
|
||||
'height': 'data(size)',
|
||||
'font-size': 9,
|
||||
'color': fg,
|
||||
'text-valign': 'bottom',
|
||||
'text-margin-y': 4,
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '90px',
|
||||
'border-width': 1,
|
||||
'border-color': border,
|
||||
'text-outline-width': 2,
|
||||
'text-outline-color': bg,
|
||||
} },
|
||||
{ selector: 'node[?pinned]', style: {
|
||||
'border-width': 3,
|
||||
'border-color': accent,
|
||||
} },
|
||||
{ selector: 'edge[type = "similarity"]', style: {
|
||||
'width': 'mapData(weight, 0.5, 1, 1, 4)',
|
||||
'line-color': fg,
|
||||
'opacity': 'mapData(weight, 0.5, 1, 0.25, 0.7)',
|
||||
'curve-style': 'bezier',
|
||||
'target-arrow-shape': 'none',
|
||||
} },
|
||||
{ selector: 'edge[type = "session"]', style: {
|
||||
'width': 1.4,
|
||||
'line-color': _cssVar('--color-muted-alt', '#6b7280'),
|
||||
'line-style': 'dashed',
|
||||
'opacity': 0.5,
|
||||
'curve-style': 'bezier',
|
||||
} },
|
||||
{ selector: 'edge[type = "manual"]', style: {
|
||||
'width': 2.4,
|
||||
'line-color': accent,
|
||||
'opacity': 0.85,
|
||||
'curve-style': 'bezier',
|
||||
} },
|
||||
{ selector: '.mg-dimmed', style: { 'opacity': 0.08 } },
|
||||
{ selector: 'node.mg-highlighted', style: {
|
||||
'border-width': 3,
|
||||
'border-color': _cssVar('--color-accent', '#00aaff'),
|
||||
} },
|
||||
{ selector: 'edge.mg-highlighted', style: { 'opacity': 1, 'width': 4 } },
|
||||
{ selector: 'node.mg-search-match', style: {
|
||||
'border-width': 3,
|
||||
'border-color': _cssVar('--warn', '#f0ad4e'),
|
||||
} },
|
||||
{ selector: 'node.mg-link-source', style: {
|
||||
'border-width': 4,
|
||||
'border-color': _cssVar('--color-accent', '#00aaff'),
|
||||
'border-style': 'double',
|
||||
} },
|
||||
{ selector: 'node:selected', style: {
|
||||
'border-width': 3,
|
||||
'border-color': fg,
|
||||
} },
|
||||
];
|
||||
}
|
||||
|
||||
function _renderGraph() {
|
||||
const canvas = document.getElementById('memory-graph-canvas');
|
||||
if (!canvas) return;
|
||||
canvas.innerHTML = '';
|
||||
const { nodes, edges } = _toElements(_graph);
|
||||
if (_cy) { _cy.destroy(); _cy = null; }
|
||||
_cy = window.cytoscape({
|
||||
container: canvas,
|
||||
elements: { nodes, edges },
|
||||
style: _cyStyle(),
|
||||
layout: { name: 'cose', animate: false, padding: 30, nodeRepulsion: 8000, idealEdgeLength: 90 },
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3,
|
||||
wheelSensitivity: 0.2,
|
||||
});
|
||||
_wireCyEvents();
|
||||
_applyFilters();
|
||||
_renderDemoBanner();
|
||||
_selectedId = null;
|
||||
_renderDetailPanel();
|
||||
}
|
||||
|
||||
function _renderDemoBanner() {
|
||||
const banner = document.getElementById('memory-graph-demo-banner');
|
||||
if (banner) banner.classList.toggle('hidden', !_isDemo);
|
||||
}
|
||||
|
||||
// ---- category filter + search + similarity threshold ----
|
||||
function _renderCategoryChips() {
|
||||
const container = document.getElementById('memory-graph-cat-chips');
|
||||
if (!container) return;
|
||||
const seen = new Set(_graph.nodes.map(n => n.category || 'fact'));
|
||||
const cats = [
|
||||
...KNOWN_CATEGORIES.filter(c => seen.has(c)),
|
||||
...[...seen].filter(c => !KNOWN_CATEGORIES.includes(c)),
|
||||
];
|
||||
container.innerHTML = '';
|
||||
const allChip = document.createElement('button');
|
||||
allChip.type = 'button';
|
||||
allChip.className = `memory-graph-cat-chip${!_activeCategory ? ' active' : ''}`;
|
||||
allChip.textContent = 'all';
|
||||
allChip.addEventListener('click', () => { _activeCategory = null; _renderCategoryChips(); _applyFilters(); });
|
||||
container.appendChild(allChip);
|
||||
cats.forEach(cat => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = `memory-graph-cat-chip${_activeCategory === cat ? ' active' : ''}`;
|
||||
chip.style.setProperty('--mg-cat-color', _categoryColor(cat));
|
||||
chip.textContent = cat;
|
||||
chip.addEventListener('click', () => { _activeCategory = cat; _renderCategoryChips(); _applyFilters(); });
|
||||
container.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function _applyFilters() {
|
||||
if (!_cy) return;
|
||||
_cy.batch(() => {
|
||||
_cy.nodes().forEach(n => {
|
||||
const visible = !_activeCategory || n.data('category') === _activeCategory;
|
||||
n.style('display', visible ? 'element' : 'none');
|
||||
});
|
||||
_cy.edges().forEach(e => {
|
||||
const src = _cy.getElementById(e.data('source'));
|
||||
const tgt = _cy.getElementById(e.data('target'));
|
||||
const endpointsVisible = src.style('display') !== 'none' && tgt.style('display') !== 'none';
|
||||
const passesSimilarity = e.data('type') !== 'similarity' || e.data('weight') >= _minSimilarity;
|
||||
e.style('display', (endpointsVisible && passesSimilarity) ? 'element' : 'none');
|
||||
});
|
||||
});
|
||||
_applySearch();
|
||||
}
|
||||
|
||||
function _applySearch() {
|
||||
if (!_cy) return;
|
||||
_cy.nodes().removeClass('mg-search-match');
|
||||
const term = _searchTerm.trim().toLowerCase();
|
||||
if (!term) return;
|
||||
// A fresh search supersedes any leftover node-selection highlight —
|
||||
// otherwise .mg-dimmed's opacity:0.08 masks nodes that match the search
|
||||
// but weren't part of the previously selected node's neighborhood.
|
||||
if (_selectedId) { _selectedId = null; _renderDetailPanel(); }
|
||||
_cy.elements().removeClass('mg-highlighted mg-dimmed');
|
||||
const matches = _cy.nodes().filter(n => {
|
||||
const src = _findNode(n.id());
|
||||
return (src?.text || '').toLowerCase().includes(term);
|
||||
});
|
||||
matches.addClass('mg-search-match');
|
||||
if (matches.length) {
|
||||
_cy.animate({ fit: { eles: matches, padding: 60 } }, { duration: 250 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- selection / highlighting ----
|
||||
function _findNode(id) {
|
||||
return _graph.nodes.find(n => n.id === id) || null;
|
||||
}
|
||||
|
||||
function _manualLinksFor(id) {
|
||||
return _graph.edges
|
||||
.filter(e => e.type === 'manual' && (e.source === id || e.target === id))
|
||||
.map(e => {
|
||||
const otherId = e.source === id ? e.target : e.source;
|
||||
return { id: otherId, node: _findNode(otherId) };
|
||||
})
|
||||
.filter(l => l.node);
|
||||
}
|
||||
|
||||
function _highlightNeighborhood(id) {
|
||||
if (!_cy) return;
|
||||
_cy.elements().removeClass('mg-highlighted mg-dimmed');
|
||||
const node = _cy.getElementById(id);
|
||||
if (!node || node.empty()) return;
|
||||
const neighborhood = node.closedNeighborhood();
|
||||
_cy.elements().difference(neighborhood).addClass('mg-dimmed');
|
||||
neighborhood.addClass('mg-highlighted');
|
||||
}
|
||||
|
||||
function _selectNode(id) {
|
||||
_selectedId = id;
|
||||
_highlightNeighborhood(id);
|
||||
_renderDetailPanel();
|
||||
}
|
||||
|
||||
function _clearSelection() {
|
||||
_selectedId = null;
|
||||
if (_cy) _cy.elements().removeClass('mg-highlighted mg-dimmed');
|
||||
_renderDetailPanel();
|
||||
}
|
||||
|
||||
function _wireCyEvents() {
|
||||
if (!_cy) return;
|
||||
_cy.on('tap', 'node', (evt) => {
|
||||
const node = evt.target;
|
||||
if (_linkMode) { _handleLinkModeClick(node.id()); return; }
|
||||
_selectNode(node.id());
|
||||
});
|
||||
_cy.on('tap', 'edge', (evt) => {
|
||||
const edge = evt.target;
|
||||
_cy.elements().removeClass('mg-highlighted mg-dimmed');
|
||||
const eles = edge.connectedNodes().union(edge);
|
||||
_cy.elements().difference(eles).addClass('mg-dimmed');
|
||||
eles.addClass('mg-highlighted');
|
||||
_selectedId = null;
|
||||
});
|
||||
_cy.on('tap', (evt) => {
|
||||
if (evt.target === _cy) _clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- link (manual relationship) mode ----
|
||||
function _setLinkMode(on) {
|
||||
_linkMode = on;
|
||||
_linkSourceId = null;
|
||||
if (_cy) _cy.nodes().removeClass('mg-link-source');
|
||||
const btn = document.getElementById('memory-graph-link-mode-btn');
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
}
|
||||
|
||||
async function _handleLinkModeClick(id) {
|
||||
if (_isDemo) { uiModule.showToast?.('Demo data — add a real memory first', 2500); return; }
|
||||
if (!_linkSourceId) {
|
||||
_linkSourceId = id;
|
||||
_cy.getElementById(id).addClass('mg-link-source');
|
||||
uiModule.showToast?.('Click another memory to connect it', 2500);
|
||||
return;
|
||||
}
|
||||
if (_linkSourceId === id) return;
|
||||
const sourceId = _linkSourceId;
|
||||
_cy.nodes().removeClass('mg-link-source');
|
||||
_linkSourceId = null;
|
||||
try {
|
||||
await _apiAddLink(sourceId, id);
|
||||
uiModule.showToast?.('Relationship added');
|
||||
await _reloadAfterMutation();
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] add link failed', err);
|
||||
uiModule.showToast?.('Could not add relationship');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- detail panel ----
|
||||
function _formatTimestamp(ts) {
|
||||
if (!ts) return 'Unknown';
|
||||
try { return new Date(ts * 1000).toLocaleString(); } catch { return 'Unknown'; }
|
||||
}
|
||||
|
||||
function _renderDetailPanel() {
|
||||
const panel = document.getElementById('memory-graph-detail');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('hidden');
|
||||
if (!_selectedId) {
|
||||
panel.innerHTML = '<div class="memory-graph-detail-empty">Select a memory to see details.</div>';
|
||||
return;
|
||||
}
|
||||
const node = _findNode(_selectedId);
|
||||
if (!node) { _selectedId = null; _renderDetailPanel(); return; }
|
||||
|
||||
const color = _categoryColor(node.category);
|
||||
const links = _manualLinksFor(_selectedId);
|
||||
panel.innerHTML = `
|
||||
<span class="memory-graph-detail-cat" style="--mg-cat-color:${color}">${escapeHtml(node.category || 'fact')}</span>
|
||||
<div class="memory-graph-detail-text" id="memory-graph-detail-text">${escapeHtml(node.text || '')}</div>
|
||||
<div class="memory-graph-detail-meta">
|
||||
<span>${node.uses || 0} use${node.uses === 1 ? '' : 's'} · ${node.pinned ? 'pinned' : 'not pinned'}</span>
|
||||
<span>${escapeHtml(_formatTimestamp(node.timestamp))}</span>
|
||||
</div>
|
||||
<div class="memory-graph-detail-actions" id="memory-graph-detail-actions">
|
||||
<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" class="danger" data-action="delete">Delete</button>
|
||||
</div>
|
||||
<div class="memory-graph-detail-links">
|
||||
<div class="memory-graph-detail-links-title">Relationships (${links.length})</div>
|
||||
${links.length ? links.map(l => `
|
||||
<div class="memory-graph-link-row" data-target="${escapeHtml(l.id)}">
|
||||
<span class="memory-graph-link-row-text">${escapeHtml((l.node.text || '').slice(0, 40))}</span>
|
||||
<span class="memory-graph-link-row-remove" title="Remove relationship">✕</span>
|
||||
</div>`).join('') : '<div style="font-size:11px;opacity:0.5;">No manual relationships yet.</div>'}
|
||||
</div>
|
||||
${_isDemo ? '<div style="font-size:10.5px;opacity:0.6;">Demo data — actions disabled. Add a real memory to try this.</div>' : ''}
|
||||
`;
|
||||
|
||||
if (_isDemo) {
|
||||
panel.querySelectorAll('button, .memory-graph-link-row-remove').forEach(b => {
|
||||
b.disabled = true;
|
||||
b.style.pointerEvents = 'none';
|
||||
b.style.opacity = '0.4';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
panel.querySelector('[data-action="pin"]')?.addEventListener('click', () => _actionPin(node));
|
||||
panel.querySelector('[data-action="edit"]')?.addEventListener('click', () => _enterEditMode(node));
|
||||
panel.querySelector('[data-action="link"]')?.addEventListener('click', () => {
|
||||
_setLinkMode(true);
|
||||
_linkSourceId = node.id;
|
||||
_cy.getElementById(node.id).addClass('mg-link-source');
|
||||
uiModule.showToast?.('Click another memory to connect it', 2500);
|
||||
});
|
||||
panel.querySelector('[data-action="delete"]')?.addEventListener('click', () => _actionDelete(node));
|
||||
panel.querySelectorAll('.memory-graph-link-row-remove').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('.memory-graph-link-row');
|
||||
const targetId = row?.dataset.target;
|
||||
if (targetId) _actionRemoveLink(node.id, targetId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _enterEditMode(node) {
|
||||
const textEl = document.getElementById('memory-graph-detail-text');
|
||||
const actions = document.getElementById('memory-graph-detail-actions');
|
||||
if (!textEl || !actions) return;
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'memory-graph-detail-textarea';
|
||||
textarea.value = node.text || '';
|
||||
textEl.replaceWith(textarea);
|
||||
textarea.focus();
|
||||
|
||||
actions.innerHTML = `
|
||||
<button type="button" data-action="save">Save</button>
|
||||
<button type="button" data-action="cancel">Cancel</button>
|
||||
`;
|
||||
actions.querySelector('[data-action="save"]').addEventListener('click', async () => {
|
||||
const newText = textarea.value.trim();
|
||||
if (!newText) { uiModule.showToast?.('Memory text cannot be empty'); return; }
|
||||
await _actionUpdateText(node, newText);
|
||||
});
|
||||
actions.querySelector('[data-action="cancel"]').addEventListener('click', () => _renderDetailPanel());
|
||||
}
|
||||
|
||||
// ---- mutations against the Milestone 1 backend ----
|
||||
function _formBody(fields) {
|
||||
const body = new URLSearchParams();
|
||||
Object.entries(fields || {}).forEach(([k, v]) => { if (v !== undefined && v !== null) body.set(k, String(v)); });
|
||||
return body.toString();
|
||||
}
|
||||
|
||||
async function _postForm(url, fields) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: _formBody(fields),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function _putForm(url, fields) {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: _formBody(fields),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function _apiAddLink(sourceId, targetId) {
|
||||
const url = `${API_BASE}/api/memory/${encodeURIComponent(sourceId)}/links?target_id=${encodeURIComponent(targetId)}`;
|
||||
const res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function _reloadAfterMutation(keepSelection = true) {
|
||||
const prevSelected = keepSelection ? _selectedId : null;
|
||||
try {
|
||||
_graph = await _fetchGraph();
|
||||
_isDemo = false;
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] reload failed', err);
|
||||
return;
|
||||
}
|
||||
_renderCategoryChips();
|
||||
_renderGraph();
|
||||
if (prevSelected && _cy && _cy.getElementById(prevSelected).nonempty()) {
|
||||
_selectNode(prevSelected);
|
||||
}
|
||||
}
|
||||
|
||||
async function _actionPin(node) {
|
||||
try {
|
||||
await _postForm(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}/pin`, { pinned: !node.pinned });
|
||||
uiModule.showToast?.(node.pinned ? 'Unpinned' : 'Pinned');
|
||||
await _reloadAfterMutation();
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] pin failed', err);
|
||||
uiModule.showToast?.('Could not update pin state');
|
||||
}
|
||||
}
|
||||
|
||||
async function _actionUpdateText(node, newText) {
|
||||
try {
|
||||
await _putForm(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}`, { text: newText, category: node.category });
|
||||
uiModule.showToast?.('Memory updated');
|
||||
await _reloadAfterMutation();
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] update failed', err);
|
||||
uiModule.showToast?.('Could not update memory');
|
||||
}
|
||||
}
|
||||
|
||||
async function _actionDelete(node) {
|
||||
const ok = await uiModule.styledConfirm(`Delete this memory?\n\n"${node.text}"`, { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/memory/${encodeURIComponent(node.id)}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
uiModule.showToast?.('Memory deleted');
|
||||
await _reloadAfterMutation(false);
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] delete failed', err);
|
||||
uiModule.showToast?.('Could not delete memory');
|
||||
}
|
||||
}
|
||||
|
||||
async function _actionRemoveLink(sourceId, targetId) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/memory/${encodeURIComponent(sourceId)}/links/${encodeURIComponent(targetId)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
uiModule.showToast?.('Relationship removed');
|
||||
await _reloadAfterMutation();
|
||||
} catch (err) {
|
||||
console.error('[memoryGraph] remove link failed', err);
|
||||
uiModule.showToast?.('Could not remove relationship');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- open / close ----
|
||||
export function isMemoryGraphOpen() {
|
||||
if (Modals.isMinimized('memory-graph-modal')) return false;
|
||||
return _open;
|
||||
}
|
||||
|
||||
export function openMemoryGraph() {
|
||||
if (_open) return;
|
||||
if (Modals.isMinimized('memory-graph-modal')) {
|
||||
Modals.restore('memory-graph-modal');
|
||||
_open = true;
|
||||
return;
|
||||
}
|
||||
_open = true;
|
||||
const modal = _getModal();
|
||||
modal.classList.remove('hidden', 'modal-minimized');
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content) {
|
||||
content.classList.remove('modal-closing', 'sheet-ready');
|
||||
content.style.transform = '';
|
||||
content.style.transition = '';
|
||||
content.style.animation = '';
|
||||
content.style.opacity = '';
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
Modals.register('memory-graph-modal', {
|
||||
railBtnId: 'tool-memory-graph-btn',
|
||||
closeFn: () => _doCloseMemoryGraph(),
|
||||
restoreFn: () => { if (_cy) _cy.resize(); },
|
||||
label: 'Memory Graph',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="12" cy="13" r="2.4"/><circle cx="6" cy="19" r="2.4"/><circle cx="18" cy="19" r="2.4"/></svg>',
|
||||
});
|
||||
const btn = document.getElementById('tool-memory-graph-btn');
|
||||
if (btn) btn.classList.add('active');
|
||||
|
||||
_escHandler = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const active = document.activeElement;
|
||||
if (active && active.id === 'memory-graph-search' && active.value) {
|
||||
active.value = '';
|
||||
_searchTerm = '';
|
||||
_applySearch();
|
||||
return;
|
||||
}
|
||||
if (_linkMode) { _setLinkMode(false); return; }
|
||||
closeMemoryGraph();
|
||||
};
|
||||
document.addEventListener('keydown', _escHandler);
|
||||
|
||||
_wireResize();
|
||||
_loadGraph();
|
||||
requestAnimationFrame(() => { if (_cy) _cy.resize(); });
|
||||
}
|
||||
|
||||
function _doCloseMemoryGraph() {
|
||||
_open = false;
|
||||
_setLinkMode(false);
|
||||
if (_modal) { _modal.style.display = 'none'; _modal.classList.add('hidden'); }
|
||||
if (_escHandler) { document.removeEventListener('keydown', _escHandler); _escHandler = null; }
|
||||
const btn = document.getElementById('tool-memory-graph-btn');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
|
||||
export function closeMemoryGraph() {
|
||||
if (!_open && !Modals.isMinimized('memory-graph-modal')) return;
|
||||
if (Modals.isRegistered('memory-graph-modal')) {
|
||||
Modals.close('memory-graph-modal');
|
||||
} else {
|
||||
_doCloseMemoryGraph();
|
||||
}
|
||||
}
|
||||
|
||||
const memoryGraphModule = { openMemoryGraph, closeMemoryGraph, isMemoryGraphOpen };
|
||||
export default memoryGraphModule;
|
||||
31
static/lib/cytoscape.min.js
vendored
Normal file
31
static/lib/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
234
static/style.css
234
static/style.css
|
|
@ -41130,3 +41130,237 @@ body.theme-frosted .modal {
|
|||
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
|
||||
.compare-pane { min-height: 60dvh; }
|
||||
}
|
||||
|
||||
/* ── Memory Graph View (beta) ── */
|
||||
|
||||
.memory-graph-modal-content {
|
||||
width: min(1180px, 94vw);
|
||||
height: min(820px, 90vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
.memory-graph-modal-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.memory-graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.memory-graph-search {
|
||||
flex: 1 1 160px;
|
||||
min-width: 120px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 12px;
|
||||
}
|
||||
.memory-graph-cat-chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.memory-graph-cat-chip {
|
||||
font-size: 10.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-graph-cat-chip.active {
|
||||
opacity: 1;
|
||||
border-color: var(--mg-cat-color, var(--accent, var(--red)));
|
||||
color: var(--mg-cat-color, var(--accent, var(--red)));
|
||||
background: color-mix(in srgb, var(--mg-cat-color, var(--accent, var(--red))) 14%, transparent);
|
||||
}
|
||||
.memory-graph-link-mode-btn {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-graph-link-mode-btn.active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 20%, transparent);
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
.memory-graph-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-graph-slider-row input[type="range"] {
|
||||
width: 80px;
|
||||
}
|
||||
.memory-graph-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.memory-graph-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.memory-graph-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.memory-graph-demo-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: color-mix(in srgb, var(--warn) 20%, var(--panel));
|
||||
border: 1px solid var(--warn);
|
||||
color: var(--warn);
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
z-index: 5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-graph-detail-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.memory-graph-detail-panel.hidden { display: none; }
|
||||
.memory-graph-detail-empty {
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
.memory-graph-detail-cat {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--mg-cat-color, var(--border));
|
||||
color: var(--mg-cat-color, var(--fg));
|
||||
width: fit-content;
|
||||
}
|
||||
.memory-graph-detail-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.memory-graph-detail-textarea {
|
||||
width: 100%;
|
||||
min-height: 70px;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
.memory-graph-detail-meta {
|
||||
font-size: 10.5px;
|
||||
color: var(--color-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.memory-graph-detail-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.memory-graph-detail-actions button {
|
||||
font-size: 11px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.memory-graph-detail-actions button:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); }
|
||||
.memory-graph-detail-actions button.danger { color: var(--red); border-color: var(--red); }
|
||||
.memory-graph-detail-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.memory-graph-detail-links-title {
|
||||
font-size: 10.5px;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.memory-graph-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--fg) 5%, transparent);
|
||||
}
|
||||
.memory-graph-link-row-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.memory-graph-link-row-remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-graph-link-row-remove:hover { opacity: 1; color: var(--red); }
|
||||
.memory-graph-legend {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--color-muted);
|
||||
background: color-mix(in srgb, var(--panel) 80%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
.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