superlocalmemory 3.0.16 → 3.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/js/clusters.js CHANGED
@@ -1,7 +1,5 @@
1
- // SuperLocalMemory V2 - Clusters View
2
- // Depends on: core.js
3
- //
4
- // Security: All dynamic values escaped via escapeHtml(). Data from local DB only.
1
+ // SuperLocalMemory V3 - Clusters View
2
+ // Part of Qualixar | https://superlocalmemory.com
5
3
 
6
4
  async function loadClusters() {
7
5
  showLoading('clusters-list', 'Loading clusters...');
@@ -18,152 +16,191 @@ async function loadClusters() {
18
16
  function renderClusters(clusters) {
19
17
  var container = document.getElementById('clusters-list');
20
18
  if (!clusters || clusters.length === 0) {
21
- showEmpty('clusters-list', 'collection', 'No clusters found. Run "slm build-graph" to generate clusters.');
19
+ showEmpty('clusters-list', 'collection', 'No clusters found yet. Clusters form automatically as you store related memories.');
22
20
  return;
23
21
  }
24
22
 
25
- var colors = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a'];
23
+ var colors = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#30cfd0', '#764ba2', '#f5576c'];
26
24
  container.textContent = '';
27
25
 
28
26
  clusters.forEach(function(cluster, idx) {
29
27
  var color = colors[idx % colors.length];
30
28
 
31
29
  var card = document.createElement('div');
32
- card.className = 'card cluster-card';
33
- card.style.cssText = 'border-color:' + color + '; cursor:pointer;';
34
- card.setAttribute('data-cluster-id', cluster.cluster_id);
35
- card.title = 'Click to filter graph to this cluster';
30
+ card.className = 'card mb-2';
31
+ card.style.borderLeft = '4px solid ' + color;
36
32
 
37
33
  var body = document.createElement('div');
38
- body.className = 'card-body';
34
+ body.className = 'card-body py-2 px-3';
35
+ body.style.cursor = 'pointer';
36
+
37
+ // Header row
38
+ var headerRow = document.createElement('div');
39
+ headerRow.className = 'd-flex justify-content-between align-items-center';
39
40
 
40
41
  var title = document.createElement('h6');
41
- title.className = 'card-title';
42
- title.textContent = 'Cluster ' + cluster.cluster_id + ' ';
42
+ title.className = 'mb-0';
43
+ title.textContent = 'Cluster ' + cluster.cluster_id;
44
+
45
+ var badges = document.createElement('div');
43
46
  var countBadge = document.createElement('span');
44
- countBadge.className = 'badge bg-secondary float-end';
47
+ countBadge.className = 'badge bg-secondary me-1';
45
48
  countBadge.textContent = cluster.member_count + ' memories';
46
- title.appendChild(countBadge);
47
- body.appendChild(title);
48
-
49
- var imp = document.createElement('p');
50
- imp.className = 'mb-2';
51
- imp.textContent = 'Avg Importance: ' + parseFloat(cluster.avg_importance).toFixed(1);
52
- body.appendChild(imp);
53
-
54
- var cats = document.createElement('p');
55
- cats.className = 'mb-2';
56
- cats.textContent = 'Categories: ' + (cluster.categories || 'None');
57
- body.appendChild(cats);
58
-
59
- var entLabel = document.createElement('strong');
60
- entLabel.textContent = 'Top Entities:';
61
- body.appendChild(entLabel);
62
- body.appendChild(document.createElement('br'));
63
-
64
- if (cluster.top_entities && cluster.top_entities.length > 0) {
65
- cluster.top_entities.forEach(function(e) {
66
- var badge = document.createElement('span');
67
- badge.className = 'badge bg-info entity-badge';
68
- badge.textContent = e.entity + ' (' + e.count + ')';
69
- body.appendChild(badge);
70
- body.appendChild(document.createTextNode(' '));
71
- });
72
- } else {
73
- var none = document.createElement('span');
74
- none.className = 'text-muted';
75
- none.textContent = 'No entities';
76
- body.appendChild(none);
49
+ badges.appendChild(countBadge);
50
+
51
+ if (cluster.avg_importance) {
52
+ var impBadge = document.createElement('span');
53
+ impBadge.className = 'badge bg-outline-primary';
54
+ impBadge.style.cssText = 'border:1px solid #667eea; color:#667eea;';
55
+ impBadge.textContent = 'imp: ' + parseFloat(cluster.avg_importance).toFixed(1);
56
+ badges.appendChild(impBadge);
57
+ }
58
+
59
+ var expandIcon = document.createElement('i');
60
+ expandIcon.className = 'bi bi-chevron-down ms-2';
61
+ expandIcon.style.transition = 'transform 0.2s';
62
+ badges.appendChild(expandIcon);
63
+
64
+ headerRow.appendChild(title);
65
+ headerRow.appendChild(badges);
66
+ body.appendChild(headerRow);
67
+
68
+ // Summary line (categories if available)
69
+ if (cluster.categories) {
70
+ var catLine = document.createElement('small');
71
+ catLine.className = 'text-muted';
72
+ catLine.textContent = cluster.categories;
73
+ body.appendChild(catLine);
77
74
  }
78
75
 
76
+ // Expandable member area (hidden by default)
77
+ var memberArea = document.createElement('div');
78
+ memberArea.className = 'mt-2';
79
+ memberArea.style.display = 'none';
80
+ memberArea.id = 'cluster-members-' + cluster.cluster_id;
81
+
82
+ var loadingText = document.createElement('div');
83
+ loadingText.className = 'text-center text-muted small py-2';
84
+ loadingText.textContent = 'Loading members...';
85
+ memberArea.appendChild(loadingText);
86
+
87
+ body.appendChild(memberArea);
79
88
  card.appendChild(body);
80
89
  container.appendChild(card);
81
90
 
82
- // v2.6.5: Click card → filter graph to this cluster
83
- card.addEventListener('click', function(e) {
84
- // Don't trigger if clicking on badge or entity
85
- if (e.target.classList.contains('entity-badge') || e.target.classList.contains('badge')) {
86
- return;
87
- }
91
+ // Click to expand/collapse
92
+ var expanded = false;
93
+ body.addEventListener('click', function(e) {
94
+ expanded = !expanded;
95
+ memberArea.style.display = expanded ? 'block' : 'none';
96
+ expandIcon.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0)';
88
97
 
89
- const clusterId = parseInt(card.getAttribute('data-cluster-id'));
90
- filterGraphToCluster(clusterId);
98
+ if (expanded && memberArea.children.length === 1 && memberArea.children[0] === loadingText) {
99
+ loadClusterMembers(cluster.cluster_id, memberArea);
100
+ }
91
101
  });
102
+ });
103
+ }
92
104
 
93
- // v2.6.5: Click entity badge → filter graph by entity
94
- if (cluster.top_entities && cluster.top_entities.length > 0) {
95
- const entityBadges = body.querySelectorAll('.entity-badge');
96
- entityBadges.forEach(function(badge) {
97
- badge.style.cursor = 'pointer';
98
- badge.title = 'Click to show memories with this entity';
99
- badge.addEventListener('click', function(e) {
100
- e.stopPropagation(); // Don't trigger card click
101
- const entityText = badge.textContent.split(' (')[0]; // Extract entity name
102
- filterGraphByEntity(entityText);
103
- });
104
- });
105
+ async function loadClusterMembers(clusterId, container) {
106
+ try {
107
+ var response = await fetch('/api/clusters/' + clusterId + '?limit=10');
108
+ var data = await response.json();
109
+ container.textContent = '';
110
+
111
+ // Show cluster summary if available
112
+ if (data.summary) {
113
+ var summaryDiv = document.createElement('div');
114
+ summaryDiv.className = 'alert alert-light border-start border-3 border-primary py-2 px-3 mb-2 small';
115
+ var summaryLabel = document.createElement('strong');
116
+ summaryLabel.textContent = 'Summary: ';
117
+ summaryDiv.appendChild(summaryLabel);
118
+ summaryDiv.appendChild(document.createTextNode(data.summary));
119
+ container.appendChild(summaryDiv);
105
120
  }
106
121
 
107
- // v2.6.5: Click "X memories" badge → show list in sidebar (future feature)
108
- countBadge.style.cursor = 'pointer';
109
- countBadge.title = 'Click to view memories in this cluster';
110
- countBadge.addEventListener('click', function(e) {
111
- e.stopPropagation(); // Don't trigger card click
112
- showClusterMemories(cluster.cluster_id);
122
+ if (!data.members || data.members.length === 0) {
123
+ var empty = document.createElement('div');
124
+ empty.className = 'text-muted small';
125
+ empty.textContent = 'No members found.';
126
+ container.appendChild(empty);
127
+ return;
128
+ }
129
+
130
+ data.members.forEach(function(m, i) {
131
+ var row = document.createElement('div');
132
+ row.className = 'border-bottom py-1';
133
+ if (i === data.members.length - 1) row.className = 'py-1';
134
+
135
+ var content = document.createElement('div');
136
+ content.className = 'small';
137
+ var text = m.content || m.summary || '';
138
+ content.textContent = (i + 1) + '. ' + (text.length > 150 ? text.substring(0, 150) + '...' : text);
139
+ row.appendChild(content);
140
+
141
+ var meta = document.createElement('div');
142
+ meta.className = 'text-muted';
143
+ meta.style.fontSize = '0.7rem';
144
+ var parts = [];
145
+ if (m.category) parts.push(m.category);
146
+ if (m.importance) parts.push('imp: ' + m.importance);
147
+ if (m.created_at) parts.push(m.created_at.substring(0, 10));
148
+ meta.textContent = parts.join(' | ');
149
+ row.appendChild(meta);
150
+
151
+ container.appendChild(row);
113
152
  });
114
- });
153
+
154
+ // View in graph button
155
+ var graphBtn = document.createElement('button');
156
+ graphBtn.className = 'btn btn-sm btn-outline-primary mt-2';
157
+ graphBtn.textContent = 'View in Knowledge Graph';
158
+ graphBtn.addEventListener('click', function(e) {
159
+ e.stopPropagation();
160
+ filterGraphToCluster(clusterId);
161
+ });
162
+ container.appendChild(graphBtn);
163
+
164
+ } catch (error) {
165
+ container.textContent = '';
166
+ var errDiv = document.createElement('div');
167
+ errDiv.className = 'text-danger small';
168
+ errDiv.textContent = 'Failed to load: ' + error.message;
169
+ container.appendChild(errDiv);
170
+ }
115
171
  }
116
172
 
117
- // v2.6.5: Filter graph to a specific cluster
118
173
  function filterGraphToCluster(clusterId) {
119
- // Switch to Graph tab
120
- const graphTab = document.querySelector('a[href="#graph"]');
121
- if (graphTab) {
122
- graphTab.click();
123
- }
174
+ var graphTab = document.querySelector('a[href="#graph"]');
175
+ if (graphTab) graphTab.click();
124
176
 
125
- // Apply filter after a delay (for tab to load)
126
177
  setTimeout(function() {
127
178
  if (typeof filterState !== 'undefined' && typeof filterByCluster === 'function' && typeof renderGraph === 'function') {
128
179
  filterState.cluster_id = clusterId;
129
- const filtered = filterByCluster(originalGraphData, clusterId);
180
+ var filtered = filterByCluster(originalGraphData, clusterId);
130
181
  renderGraph(filtered);
131
-
132
- // Update URL
133
- const url = new URL(window.location);
182
+ var url = new URL(window.location);
134
183
  url.searchParams.set('cluster_id', clusterId);
135
184
  window.history.replaceState({}, '', url);
136
185
  }
137
186
  }, 300);
138
187
  }
139
188
 
140
- // v2.6.5: Filter graph by entity
141
189
  function filterGraphByEntity(entity) {
142
- // Switch to Graph tab
143
- const graphTab = document.querySelector('a[href="#graph"]');
144
- if (graphTab) {
145
- graphTab.click();
146
- }
190
+ var graphTab = document.querySelector('a[href="#graph"]');
191
+ if (graphTab) graphTab.click();
147
192
 
148
- // Apply filter after a delay
149
193
  setTimeout(function() {
150
194
  if (typeof filterState !== 'undefined' && typeof filterByEntity === 'function' && typeof renderGraph === 'function') {
151
195
  filterState.entity = entity;
152
- const filtered = filterByEntity(originalGraphData, entity);
196
+ var filtered = filterByEntity(originalGraphData, entity);
153
197
  renderGraph(filtered);
154
198
  }
155
199
  }, 300);
156
200
  }
157
201
 
158
- // v2.6.5: Show memories in a cluster (future: sidebar list)
159
202
  function showClusterMemories(clusterId) {
160
- // For now, just filter Memories tab
161
- const memoriesTab = document.querySelector('a[href="#memories"]');
162
- if (memoriesTab) {
163
- memoriesTab.click();
164
- }
165
-
166
- // TODO: Implement sidebar memory list view
167
- console.log('Show memories for cluster', clusterId);
168
- showToast('Filtering memories for cluster ' + clusterId);
203
+ var memoriesTab = document.querySelector('a[href="#memories"]');
204
+ if (memoriesTab) memoriesTab.click();
205
+ if (typeof showToast === 'function') showToast('Filtering memories for cluster ' + clusterId);
169
206
  }
@@ -98,8 +98,10 @@ function transformDataForCytoscape(data) {
98
98
 
99
99
  // Add nodes
100
100
  data.nodes.forEach(node => {
101
- const label = node.category || node.project_name || `Memory #${node.id}`;
102
101
  const contentPreview = node.content_preview || node.summary || node.content || '';
102
+ // Label: first 4 words of content (readable on node), fallback to category
103
+ const contentWords = contentPreview.split(/\s+/).slice(0, 4).join(' ');
104
+ const label = contentWords || node.category || `Memory #${node.id}`;
103
105
  const preview = contentPreview.substring(0, 50) + (contentPreview.length > 50 ? '...' : '');
104
106
 
105
107
  elements.push({
@@ -32,7 +32,7 @@ function addCytoscapeInteractions() {
32
32
  cy.elements().removeClass('highlighted').removeClass('dimmed');
33
33
  });
34
34
 
35
- // Single click: Open modal preview
35
+ // Single click: Open modal preview (source='graph' for context-aware buttons)
36
36
  cy.on('tap', 'node', function(evt) {
37
37
  const node = evt.target;
38
38
  openMemoryModal(node);
@@ -136,11 +136,8 @@ function openMemoryModal(node) {
136
136
  created_at: node.data('created_at')
137
137
  };
138
138
 
139
- // Call existing openMemoryDetail function from modal.js
140
139
  if (typeof openMemoryDetail === 'function') {
141
- openMemoryDetail(memoryData);
142
- } else {
143
- console.error('openMemoryDetail function not found. Is modal.js loaded?');
140
+ openMemoryDetail(memoryData, 'graph'); // source='graph': show Expand Neighbors, hide View Original
144
141
  }
145
142
  }
146
143
 
package/ui/js/memories.js CHANGED
@@ -55,11 +55,14 @@ function renderMemoriesTable(memories, showScores) {
55
55
  + '</div></div></td>';
56
56
  }
57
57
 
58
+ var memId = mem.memory_id || mem.id;
59
+ var expandBtnHtml = '<button class="btn btn-sm btn-outline-secondary expand-facts-btn ms-1" data-memory-id="' + escapeHtml(String(memId)) + '" title="View atomic facts">&#9660;</button>';
60
+
58
61
  rows += '<tr data-mem-idx="' + idx + '">'
59
62
  + '<td>' + escapeHtml(String(mem.id)) + '</td>'
60
63
  + '<td><span class="badge bg-primary">' + escapeHtml(mem.category || 'None') + '</span></td>'
61
64
  + '<td><small>' + escapeHtml(mem.project_name || '-') + '</small></td>'
62
- + '<td class="memory-content" title="' + escapeHtml(content) + '">' + escapeHtml(contentPreview) + '</td>'
65
+ + '<td class="memory-content" title="' + escapeHtml(content) + '">' + escapeHtml(contentPreview) + expandBtnHtml + '</td>'
63
66
  + scoreCell
64
67
  + '<td><span class="badge bg-' + importanceClass + ' badge-importance">' + escapeHtml(String(importance)) + '</span></td>'
65
68
  + '<td>' + escapeHtml(String(mem.cluster_id || '-')) + '</td>'
@@ -87,8 +90,18 @@ function renderMemoriesTable(memories, showScores) {
87
90
  table.addEventListener('click', function(e) {
88
91
  var th = e.target.closest('th.sortable');
89
92
  if (th) { handleSort(th); return; }
93
+
94
+ // Expand facts button
95
+ var expandBtn = e.target.closest('.expand-facts-btn');
96
+ if (expandBtn) {
97
+ e.stopPropagation();
98
+ var memId = expandBtn.getAttribute('data-memory-id');
99
+ toggleFactsExpansion(expandBtn, memId);
100
+ return;
101
+ }
102
+
90
103
  var row = e.target.closest('tr[data-mem-idx]');
91
- if (row) {
104
+ if (row && !e.target.closest('.expand-facts-btn')) {
92
105
  var idx = parseInt(row.getAttribute('data-mem-idx'), 10);
93
106
  if (window._slmMemories && window._slmMemories[idx]) {
94
107
  openMemoryDetail(window._slmMemories[idx]);
@@ -169,6 +182,56 @@ function scrollToMemory(memoryId) {
169
182
  scrollToMemoryInTable(memoryId);
170
183
  }
171
184
 
185
+ async function toggleFactsExpansion(btn, memoryId) {
186
+ var row = btn.closest('tr');
187
+ if (!row) return;
188
+ var existingExpansion = row.nextElementSibling;
189
+ if (existingExpansion && existingExpansion.classList.contains('facts-expansion-row')) {
190
+ existingExpansion.remove();
191
+ btn.innerHTML = '&#9660;';
192
+ return;
193
+ }
194
+
195
+ btn.innerHTML = '&#8987;';
196
+ try {
197
+ var resp = await fetch('/api/memories/' + encodeURIComponent(memoryId) + '/facts');
198
+ var data = await resp.json();
199
+ var expRow = document.createElement('tr');
200
+ expRow.className = 'facts-expansion-row';
201
+ var expCell = document.createElement('td');
202
+ expCell.colSpan = 8;
203
+ expCell.style.cssText = 'background:#f8f9fa; padding:8px 16px;';
204
+
205
+ if (data.facts && data.facts.length > 0) {
206
+ var label = document.createElement('small');
207
+ label.className = 'text-muted';
208
+ label.textContent = data.fact_count + ' atomic facts:';
209
+ expCell.appendChild(label);
210
+
211
+ data.facts.forEach(function(f, i) {
212
+ var fDiv = document.createElement('div');
213
+ fDiv.className = 'small py-1' + (i < data.facts.length - 1 ? ' border-bottom' : '');
214
+ var badge = document.createElement('span');
215
+ badge.className = 'badge bg-secondary me-1';
216
+ badge.style.fontSize = '0.65rem';
217
+ badge.textContent = f.fact_type;
218
+ fDiv.appendChild(badge);
219
+ fDiv.appendChild(document.createTextNode(f.content.substring(0, 200)));
220
+ expCell.appendChild(fDiv);
221
+ });
222
+ } else {
223
+ expCell.textContent = 'No atomic facts found for this memory.';
224
+ }
225
+
226
+ expRow.appendChild(expCell);
227
+ row.parentNode.insertBefore(expRow, row.nextSibling);
228
+ btn.innerHTML = '&#9650;';
229
+ } catch (err) {
230
+ btn.innerHTML = '&#9660;';
231
+ console.error('Failed to load facts:', err);
232
+ }
233
+ }
234
+
172
235
  function scrollToMemoryInTable(memoryId) {
173
236
  const memId = String(memoryId);
174
237
 
package/ui/js/modal.js CHANGED
@@ -6,8 +6,11 @@
6
6
 
7
7
  var currentMemoryDetail = null;
8
8
 
9
- function openMemoryDetail(mem) {
9
+ function openMemoryDetail(mem, source) {
10
+ // source: 'graph', 'recall', 'memories', or undefined
10
11
  currentMemoryDetail = mem;
12
+ var fromGraph = source === 'graph';
13
+ var fromRecall = source === 'recall';
11
14
  var body = document.getElementById('memory-detail-body');
12
15
  if (!mem) {
13
16
  body.textContent = 'No memory data';
@@ -62,55 +65,89 @@ function openMemoryDetail(mem) {
62
65
 
63
66
  body.appendChild(dl);
64
67
 
65
- // Graph action buttons (v2.6.5)
66
- if (mem.cluster_id || mem.id) {
68
+ // Context-aware action buttons
69
+ if (mem.id) {
67
70
  body.appendChild(document.createElement('hr'));
68
71
 
69
72
  var actionsDiv = document.createElement('div');
70
73
  actionsDiv.className = 'memory-detail-graph-actions';
71
74
  actionsDiv.style.cssText = 'display:flex; gap:10px; flex-wrap:wrap;';
72
75
 
73
- // Button 1: View Full Memory (navigate to Memories tab)
74
- var viewBtn = document.createElement('button');
75
- viewBtn.className = 'btn btn-primary btn-sm';
76
- var viewIcon = document.createElement('i');
77
- viewIcon.className = 'bi bi-journal-text';
78
- viewBtn.appendChild(viewIcon);
79
- viewBtn.appendChild(document.createTextNode(' View Full Memory'));
80
- viewBtn.onclick = function() {
81
- modal.hide();
82
- if (typeof navigateToMemoryTab === 'function') {
83
- navigateToMemoryTab(mem.id);
84
- } else {
85
- // Fallback: just switch tab
86
- const memoriesTab = document.querySelector('a[href="#memories"]');
87
- if (memoriesTab) memoriesTab.click();
88
- }
89
- };
90
- actionsDiv.appendChild(viewBtn);
76
+ // "View Original Memory" shown on Recall Lab + Memories, hidden on Graph
77
+ // (On Graph the node IS the memory; on Recall Lab we have a fact, not the original)
78
+ if (!fromGraph) {
79
+ var viewBtn = document.createElement('button');
80
+ viewBtn.className = 'btn btn-primary btn-sm';
81
+ viewBtn.innerHTML = '<i class="bi bi-journal-text"></i> View Original Memory';
82
+ viewBtn.onclick = function() {
83
+ var mid = mem.memory_id || mem.id;
84
+ viewBtn.disabled = true;
85
+ viewBtn.textContent = 'Loading...';
86
+ fetch('/api/memories/' + encodeURIComponent(mid) + '/facts')
87
+ .then(function(r) { return r.json(); })
88
+ .then(function(data) {
89
+ if (data.ok && data.original_content) {
90
+ contentDiv.textContent = '';
91
+ var origLabel = document.createElement('small');
92
+ origLabel.className = 'text-muted d-block mb-1';
93
+ origLabel.textContent = 'Original memory (' + (data.fact_count || 0) + ' atomic facts extracted):';
94
+ contentDiv.appendChild(origLabel);
95
+ var origText = document.createElement('div');
96
+ origText.style.cssText = 'white-space:pre-wrap;background:#f8f9fa;padding:10px;border-radius:6px;margin-bottom:8px;';
97
+ origText.textContent = data.original_content;
98
+ contentDiv.appendChild(origText);
99
+ if (data.facts && data.facts.length > 0) {
100
+ var toggle = document.createElement('button');
101
+ toggle.className = 'btn btn-sm btn-outline-secondary mb-2';
102
+ toggle.textContent = 'Show atomic facts (' + data.facts.length + ')';
103
+ var factsDiv = document.createElement('div');
104
+ factsDiv.style.display = 'none';
105
+ data.facts.forEach(function(f) {
106
+ var fDiv = document.createElement('div');
107
+ fDiv.className = 'small py-1 border-bottom';
108
+ var badge = document.createElement('span');
109
+ badge.className = 'badge bg-secondary me-1';
110
+ badge.style.fontSize = '0.6rem';
111
+ badge.textContent = f.fact_type;
112
+ fDiv.appendChild(badge);
113
+ fDiv.appendChild(document.createTextNode(f.content));
114
+ factsDiv.appendChild(fDiv);
115
+ });
116
+ toggle.onclick = function() {
117
+ var hidden = factsDiv.style.display === 'none';
118
+ factsDiv.style.display = hidden ? 'block' : 'none';
119
+ toggle.textContent = hidden ? 'Hide atomic facts' : 'Show atomic facts (' + data.facts.length + ')';
120
+ };
121
+ contentDiv.appendChild(toggle);
122
+ contentDiv.appendChild(factsDiv);
123
+ }
124
+ viewBtn.textContent = 'Showing original';
125
+ } else {
126
+ viewBtn.textContent = 'Not available';
127
+ }
128
+ }).catch(function() {
129
+ viewBtn.textContent = 'Failed to load';
130
+ viewBtn.disabled = false;
131
+ });
132
+ };
133
+ actionsDiv.appendChild(viewBtn);
134
+ }
91
135
 
92
- // Button 2: Expand Neighbors (show connected nodes in graph)
93
- var expandBtn = document.createElement('button');
94
- expandBtn.className = 'btn btn-outline-secondary btn-sm';
95
- var expandIcon = document.createElement('i');
96
- expandIcon.className = 'bi bi-diagram-3';
97
- expandBtn.appendChild(expandIcon);
98
- expandBtn.appendChild(document.createTextNode(' Expand Neighbors'));
99
- expandBtn.onclick = function() {
100
- modal.hide();
101
- // Switch to Graph tab
102
- const graphTab = document.querySelector('a[href="#graph"]');
103
- if (graphTab) graphTab.click();
104
- // Expand neighbors after a delay
105
- setTimeout(function() {
106
- if (typeof expandNeighbors === 'function') {
107
- expandNeighbors(mem.id);
108
- }
109
- }, 500);
110
- };
111
- actionsDiv.appendChild(expandBtn);
136
+ // "Expand Neighbors" shown on Graph, hidden elsewhere (no graph context)
137
+ if (fromGraph) {
138
+ var expandBtn = document.createElement('button');
139
+ expandBtn.className = 'btn btn-outline-secondary btn-sm';
140
+ expandBtn.innerHTML = '<i class="bi bi-diagram-3"></i> Expand Neighbors';
141
+ expandBtn.onclick = function() {
142
+ modal.hide();
143
+ setTimeout(function() {
144
+ if (typeof expandNeighbors === 'function') expandNeighbors(mem.id);
145
+ }, 300);
146
+ };
147
+ actionsDiv.appendChild(expandBtn);
148
+ }
112
149
 
113
- // Button 3: Filter to Cluster (show only this cluster in graph)
150
+ // "Filter to Cluster" always available if cluster exists
114
151
  if (mem.cluster_id) {
115
152
  var filterBtn = document.createElement('button');
116
153
  filterBtn.className = 'btn btn-outline-info btn-sm';