superlocalmemory 2.3.2 → 2.3.4
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/api_server.py +659 -0
- package/bin/slm +15 -1
- package/package.json +5 -2
- package/ui/app.js +654 -0
- package/ui/index.html +525 -0
- package/ui_server.py +1480 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// SuperLocalMemory V2 - UI Application
|
|
2
|
+
// Note: All data from API is from our own trusted local database.
|
|
3
|
+
// All user-facing strings are escaped via escapeHtml() before DOM insertion.
|
|
4
|
+
// innerHTML usage is safe here: all dynamic values are sanitized, and no
|
|
5
|
+
// external/untrusted input reaches the DOM.
|
|
6
|
+
|
|
7
|
+
let graphData = { nodes: [], links: [] };
|
|
8
|
+
let currentMemoryDetail = null; // Memory currently shown in modal
|
|
9
|
+
let lastSearchResults = null; // Cached search results for export
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Dark Mode
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
function initDarkMode() {
|
|
16
|
+
var saved = localStorage.getItem('slm-theme');
|
|
17
|
+
var theme;
|
|
18
|
+
if (saved) {
|
|
19
|
+
theme = saved;
|
|
20
|
+
} else {
|
|
21
|
+
// Respect system preference on first load
|
|
22
|
+
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
23
|
+
}
|
|
24
|
+
applyTheme(theme);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applyTheme(theme) {
|
|
28
|
+
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
29
|
+
var icon = document.getElementById('theme-icon');
|
|
30
|
+
if (icon) {
|
|
31
|
+
icon.className = theme === 'dark' ? 'bi bi-moon-stars-fill' : 'bi bi-sun-fill';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toggleDarkMode() {
|
|
36
|
+
var current = document.documentElement.getAttribute('data-bs-theme');
|
|
37
|
+
var next = current === 'dark' ? 'light' : 'dark';
|
|
38
|
+
localStorage.setItem('slm-theme', next);
|
|
39
|
+
applyTheme(next);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Animated Counter
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
function animateCounter(elementId, target) {
|
|
47
|
+
var el = document.getElementById(elementId);
|
|
48
|
+
if (!el) return;
|
|
49
|
+
var duration = 600;
|
|
50
|
+
var startTime = null;
|
|
51
|
+
|
|
52
|
+
function step(timestamp) {
|
|
53
|
+
if (!startTime) startTime = timestamp;
|
|
54
|
+
var progress = Math.min((timestamp - startTime) / duration, 1);
|
|
55
|
+
var eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
|
56
|
+
el.textContent = Math.floor(eased * target).toLocaleString();
|
|
57
|
+
if (progress < 1) {
|
|
58
|
+
requestAnimationFrame(step);
|
|
59
|
+
} else {
|
|
60
|
+
el.textContent = target.toLocaleString();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (target === 0) {
|
|
65
|
+
el.textContent = '0';
|
|
66
|
+
} else {
|
|
67
|
+
requestAnimationFrame(step);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// HTML Escaping — all dynamic text MUST pass through this before DOM insertion
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
function escapeHtml(text) {
|
|
76
|
+
if (!text) return '';
|
|
77
|
+
var div = document.createElement('div');
|
|
78
|
+
div.appendChild(document.createTextNode(String(text)));
|
|
79
|
+
return div.innerHTML;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Loading / Empty State helpers
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
function showLoading(containerId, message) {
|
|
87
|
+
var el = document.getElementById(containerId);
|
|
88
|
+
if (!el) return;
|
|
89
|
+
// Build DOM nodes instead of innerHTML for loading state
|
|
90
|
+
el.textContent = '';
|
|
91
|
+
var wrapper = document.createElement('div');
|
|
92
|
+
wrapper.className = 'loading';
|
|
93
|
+
var spinner = document.createElement('div');
|
|
94
|
+
spinner.className = 'spinner-border text-primary';
|
|
95
|
+
spinner.setAttribute('role', 'status');
|
|
96
|
+
var msg = document.createElement('div');
|
|
97
|
+
msg.textContent = message || 'Loading...';
|
|
98
|
+
wrapper.appendChild(spinner);
|
|
99
|
+
wrapper.appendChild(msg);
|
|
100
|
+
el.appendChild(wrapper);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function showEmpty(containerId, icon, message) {
|
|
104
|
+
var el = document.getElementById(containerId);
|
|
105
|
+
if (!el) return;
|
|
106
|
+
el.textContent = '';
|
|
107
|
+
var wrapper = document.createElement('div');
|
|
108
|
+
wrapper.className = 'empty-state';
|
|
109
|
+
var iconEl = document.createElement('i');
|
|
110
|
+
iconEl.className = 'bi bi-' + icon + ' d-block';
|
|
111
|
+
var p = document.createElement('p');
|
|
112
|
+
p.textContent = message;
|
|
113
|
+
wrapper.appendChild(iconEl);
|
|
114
|
+
wrapper.appendChild(p);
|
|
115
|
+
el.appendChild(wrapper);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================================================
|
|
119
|
+
// Safe HTML builder — constructs sanitized HTML strings from trusted templates
|
|
120
|
+
// and escaped dynamic values. Used for table/card rendering where DOM-node-by-
|
|
121
|
+
// node construction would be impractical for 50+ row tables.
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
function safeHtml(templateParts) {
|
|
125
|
+
// Tagged template literal helper: safeHtml`<b>${userValue}</b>`
|
|
126
|
+
// All interpolated values are auto-escaped.
|
|
127
|
+
var args = Array.prototype.slice.call(arguments, 1);
|
|
128
|
+
var result = '';
|
|
129
|
+
for (var i = 0; i < templateParts.length; i++) {
|
|
130
|
+
result += templateParts[i];
|
|
131
|
+
if (i < args.length) {
|
|
132
|
+
result += escapeHtml(String(args[i]));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Stats
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
async function loadStats() {
|
|
143
|
+
try {
|
|
144
|
+
var response = await fetch('/api/stats');
|
|
145
|
+
var data = await response.json();
|
|
146
|
+
animateCounter('stat-memories', data.overview.total_memories);
|
|
147
|
+
animateCounter('stat-clusters', data.overview.total_clusters);
|
|
148
|
+
animateCounter('stat-nodes', data.overview.graph_nodes);
|
|
149
|
+
animateCounter('stat-edges', data.overview.graph_edges);
|
|
150
|
+
populateFilters(data.categories, data.projects);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Error loading stats:', error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function populateFilters(categories, projects) {
|
|
157
|
+
var categorySelect = document.getElementById('filter-category');
|
|
158
|
+
var projectSelect = document.getElementById('filter-project');
|
|
159
|
+
categories.forEach(function(cat) {
|
|
160
|
+
if (cat.category) {
|
|
161
|
+
var option = document.createElement('option');
|
|
162
|
+
option.value = cat.category;
|
|
163
|
+
option.textContent = cat.category + ' (' + cat.count + ')';
|
|
164
|
+
categorySelect.appendChild(option);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
projects.forEach(function(proj) {
|
|
168
|
+
if (proj.project_name) {
|
|
169
|
+
var option = document.createElement('option');
|
|
170
|
+
option.value = proj.project_name;
|
|
171
|
+
option.textContent = proj.project_name + ' (' + proj.count + ')';
|
|
172
|
+
projectSelect.appendChild(option);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Graph
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
async function loadGraph() {
|
|
182
|
+
var maxNodes = document.getElementById('graph-max-nodes').value;
|
|
183
|
+
try {
|
|
184
|
+
var response = await fetch('/api/graph?max_nodes=' + maxNodes);
|
|
185
|
+
graphData = await response.json();
|
|
186
|
+
renderGraph(graphData);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Error loading graph:', error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderGraph(data) {
|
|
193
|
+
var container = document.getElementById('graph-container');
|
|
194
|
+
container.textContent = '';
|
|
195
|
+
var width = container.clientWidth || 1200;
|
|
196
|
+
var height = 600;
|
|
197
|
+
var svg = d3.select('#graph-container').append('svg').attr('width', width).attr('height', height);
|
|
198
|
+
var tooltip = d3.select('body').append('div').attr('class', 'tooltip-custom').style('opacity', 0);
|
|
199
|
+
var colorScale = d3.scaleOrdinal(d3.schemeCategory10);
|
|
200
|
+
var simulation = d3.forceSimulation(data.nodes).force('link', d3.forceLink(data.links).id(function(d) { return d.id; }).distance(100)).force('charge', d3.forceManyBody().strength(-200)).force('center', d3.forceCenter(width / 2, height / 2)).force('collision', d3.forceCollide().radius(20));
|
|
201
|
+
var link = svg.append('g').selectAll('line').data(data.links).enter().append('line').attr('class', 'link').attr('stroke-width', function(d) { return Math.sqrt(d.weight * 2); });
|
|
202
|
+
var node = svg.append('g').selectAll('circle').data(data.nodes).enter().append('circle').attr('class', 'node').attr('r', function(d) { return 5 + (d.importance || 5); }).attr('fill', function(d) { return colorScale(d.cluster_id || 0); }).call(d3.drag().on('start', dragStarted).on('drag', dragged).on('end', dragEnded)).on('mouseover', function(event, d) { tooltip.transition().duration(200).style('opacity', .9); tooltip.text((d.category || 'Uncategorized') + ': ' + (d.content_preview || d.summary || 'No content')).style('left', (event.pageX + 10) + 'px').style('top', (event.pageY - 28) + 'px'); }).on('mouseout', function() { tooltip.transition().duration(500).style('opacity', 0); }).on('click', function(event, d) { openMemoryDetail(d); });
|
|
203
|
+
simulation.on('tick', function() { link.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; }).attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; }); node.attr('cx', function(d) { return d.x; }).attr('cy', function(d) { return d.y; }); });
|
|
204
|
+
function dragStarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
|
205
|
+
function dragged(event, d) { d.fx = event.x; d.fy = event.y; }
|
|
206
|
+
function dragEnded(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Memories
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
async function loadMemories() {
|
|
214
|
+
var category = document.getElementById('filter-category').value;
|
|
215
|
+
var project = document.getElementById('filter-project').value;
|
|
216
|
+
var url = '/api/memories?limit=50';
|
|
217
|
+
if (category) url += '&category=' + encodeURIComponent(category);
|
|
218
|
+
if (project) url += '&project_name=' + encodeURIComponent(project);
|
|
219
|
+
|
|
220
|
+
showLoading('memories-list', 'Loading memories...');
|
|
221
|
+
try {
|
|
222
|
+
var response = await fetch(url);
|
|
223
|
+
var data = await response.json();
|
|
224
|
+
lastSearchResults = null; // Clear search cache when browsing
|
|
225
|
+
var exportBtn = document.getElementById('export-search-btn');
|
|
226
|
+
if (exportBtn) exportBtn.style.display = 'none';
|
|
227
|
+
renderMemoriesTable(data.memories, false);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.error('Error loading memories:', error);
|
|
230
|
+
showEmpty('memories-list', 'exclamation-triangle', 'Failed to load memories');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderMemoriesTable(memories, showScores) {
|
|
235
|
+
var container = document.getElementById('memories-list');
|
|
236
|
+
if (!memories || memories.length === 0) {
|
|
237
|
+
showEmpty('memories-list', 'journal-x', 'No memories found. Try a different search or filter.');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Store memories for row-click access
|
|
242
|
+
window._slmMemories = memories;
|
|
243
|
+
|
|
244
|
+
var scoreHeader = showScores ? '<th>Score</th>' : '';
|
|
245
|
+
|
|
246
|
+
// All dynamic values below are escaped via escapeHtml() — safe for innerHTML.
|
|
247
|
+
var rows = '';
|
|
248
|
+
memories.forEach(function(mem, idx) {
|
|
249
|
+
var content = mem.summary || mem.content || '';
|
|
250
|
+
var contentPreview = content.length > 100 ? content.substring(0, 100) + '...' : content;
|
|
251
|
+
var importance = mem.importance || 5;
|
|
252
|
+
var importanceClass = importance >= 8 ? 'success' : importance >= 5 ? 'warning' : 'secondary';
|
|
253
|
+
|
|
254
|
+
var scoreCell = '';
|
|
255
|
+
if (showScores) {
|
|
256
|
+
var score = mem.score || 0;
|
|
257
|
+
var pct = Math.round(score * 100);
|
|
258
|
+
var barColor = pct >= 70 ? '#43e97b' : pct >= 40 ? '#f9c74f' : '#f94144';
|
|
259
|
+
scoreCell = '<td><span class="score-label">' + escapeHtml(String(pct)) + '%</span>'
|
|
260
|
+
+ '<div class="score-bar-container"><div class="score-bar">'
|
|
261
|
+
+ '<div class="score-bar-fill" style="width:' + pct + '%;background:' + barColor + '"></div>'
|
|
262
|
+
+ '</div></div></td>';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
rows += '<tr data-mem-idx="' + idx + '">'
|
|
266
|
+
+ '<td>' + escapeHtml(String(mem.id)) + '</td>'
|
|
267
|
+
+ '<td><span class="badge bg-primary">' + escapeHtml(mem.category || 'None') + '</span></td>'
|
|
268
|
+
+ '<td><small>' + escapeHtml(mem.project_name || '-') + '</small></td>'
|
|
269
|
+
+ '<td class="memory-content" title="' + escapeHtml(content) + '">' + escapeHtml(contentPreview) + '</td>'
|
|
270
|
+
+ scoreCell
|
|
271
|
+
+ '<td><span class="badge bg-' + importanceClass + ' badge-importance">' + escapeHtml(String(importance)) + '</span></td>'
|
|
272
|
+
+ '<td>' + escapeHtml(String(mem.cluster_id || '-')) + '</td>'
|
|
273
|
+
+ '<td><small>' + escapeHtml(formatDate(mem.created_at)) + '</small></td>'
|
|
274
|
+
+ '</tr>';
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
var html = '<table class="table table-hover memory-table"><thead><tr>'
|
|
278
|
+
+ '<th>ID</th><th>Category</th><th>Project</th><th>Content</th>'
|
|
279
|
+
+ scoreHeader
|
|
280
|
+
+ '<th>Importance</th><th>Cluster</th><th>Created</th>'
|
|
281
|
+
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
282
|
+
|
|
283
|
+
// Safe: all interpolated values above are escaped via escapeHtml()
|
|
284
|
+
container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
|
|
285
|
+
|
|
286
|
+
// Attach click handlers via delegation
|
|
287
|
+
var table = container.querySelector('table');
|
|
288
|
+
if (table) {
|
|
289
|
+
table.addEventListener('click', function(e) {
|
|
290
|
+
var row = e.target.closest('tr[data-mem-idx]');
|
|
291
|
+
if (row) {
|
|
292
|
+
var idx = parseInt(row.getAttribute('data-mem-idx'), 10);
|
|
293
|
+
if (window._slmMemories && window._slmMemories[idx]) {
|
|
294
|
+
openMemoryDetail(window._slmMemories[idx]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// Search
|
|
303
|
+
// ============================================================================
|
|
304
|
+
|
|
305
|
+
async function searchMemories() {
|
|
306
|
+
var query = document.getElementById('search-query').value;
|
|
307
|
+
if (!query.trim()) { loadMemories(); return; }
|
|
308
|
+
|
|
309
|
+
showLoading('memories-list', 'Searching...');
|
|
310
|
+
try {
|
|
311
|
+
var response = await fetch('/api/search', {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify({ query: query, limit: 20, min_score: 0.3 })
|
|
315
|
+
});
|
|
316
|
+
var data = await response.json();
|
|
317
|
+
|
|
318
|
+
// Sort by relevance score descending
|
|
319
|
+
var results = data.results || [];
|
|
320
|
+
results.sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
|
|
321
|
+
|
|
322
|
+
// Cache for export
|
|
323
|
+
lastSearchResults = results;
|
|
324
|
+
|
|
325
|
+
// Show export search results button
|
|
326
|
+
var exportBtn = document.getElementById('export-search-btn');
|
|
327
|
+
if (exportBtn) exportBtn.style.display = results.length > 0 ? '' : 'none';
|
|
328
|
+
|
|
329
|
+
renderMemoriesTable(results, true);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error('Error searching:', error);
|
|
332
|
+
showEmpty('memories-list', 'exclamation-triangle', 'Search failed. Please try again.');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// Memory Detail Modal
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
function openMemoryDetail(mem) {
|
|
341
|
+
currentMemoryDetail = mem;
|
|
342
|
+
var body = document.getElementById('memory-detail-body');
|
|
343
|
+
if (!mem) {
|
|
344
|
+
body.textContent = 'No memory data';
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
var content = mem.content || mem.summary || '(no content)';
|
|
349
|
+
var tags = mem.tags || '';
|
|
350
|
+
var importance = mem.importance || 5;
|
|
351
|
+
var importanceClass = importance >= 8 ? 'success' : importance >= 5 ? 'warning' : 'secondary';
|
|
352
|
+
|
|
353
|
+
var scoreBlock = '';
|
|
354
|
+
if (typeof mem.score === 'number') {
|
|
355
|
+
var pct = Math.round(mem.score * 100);
|
|
356
|
+
var barColor = pct >= 70 ? '#43e97b' : pct >= 40 ? '#f9c74f' : '#f94144';
|
|
357
|
+
scoreBlock = '<dt>Relevance Score</dt><dd><span class="score-label">'
|
|
358
|
+
+ escapeHtml(String(pct)) + '%</span>'
|
|
359
|
+
+ '<div class="score-bar-container"><div class="score-bar">'
|
|
360
|
+
+ '<div class="score-bar-fill" style="width:' + pct + '%;background:' + barColor + '"></div>'
|
|
361
|
+
+ '</div></div></dd>';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// All values escaped — safe for innerHTML
|
|
365
|
+
var html = '<div class="memory-detail-content">' + escapeHtml(content) + '</div>'
|
|
366
|
+
+ '<hr>'
|
|
367
|
+
+ '<dl class="memory-detail-meta row">'
|
|
368
|
+
+ '<div class="col-md-6">'
|
|
369
|
+
+ '<dt>ID</dt><dd>' + escapeHtml(String(mem.id || '-')) + '</dd>'
|
|
370
|
+
+ '<dt>Category</dt><dd><span class="badge bg-primary">' + escapeHtml(mem.category || 'None') + '</span></dd>'
|
|
371
|
+
+ '<dt>Project</dt><dd>' + escapeHtml(mem.project_name || '-') + '</dd>'
|
|
372
|
+
+ '<dt>Tags</dt><dd>' + (tags ? formatTags(tags) : '<span class="text-muted">None</span>') + '</dd>'
|
|
373
|
+
+ '</div>'
|
|
374
|
+
+ '<div class="col-md-6">'
|
|
375
|
+
+ '<dt>Importance</dt><dd><span class="badge bg-' + importanceClass + '">' + escapeHtml(String(importance)) + '/10</span></dd>'
|
|
376
|
+
+ '<dt>Cluster</dt><dd>' + escapeHtml(String(mem.cluster_id || '-')) + '</dd>'
|
|
377
|
+
+ '<dt>Created</dt><dd>' + escapeHtml(formatDateFull(mem.created_at)) + '</dd>'
|
|
378
|
+
+ (mem.updated_at ? '<dt>Updated</dt><dd>' + escapeHtml(formatDateFull(mem.updated_at)) + '</dd>' : '')
|
|
379
|
+
+ scoreBlock
|
|
380
|
+
+ '</div>'
|
|
381
|
+
+ '</dl>';
|
|
382
|
+
|
|
383
|
+
body.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
|
|
384
|
+
|
|
385
|
+
var modal = new bootstrap.Modal(document.getElementById('memoryDetailModal'));
|
|
386
|
+
modal.show();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function formatTags(tags) {
|
|
390
|
+
if (!tags) return '';
|
|
391
|
+
var tagList = typeof tags === 'string' ? tags.split(',') : tags;
|
|
392
|
+
return tagList.map(function(t) {
|
|
393
|
+
var tag = t.trim();
|
|
394
|
+
return tag ? '<span class="badge bg-secondary me-1">' + escapeHtml(tag) + '</span>' : '';
|
|
395
|
+
}).join('');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ============================================================================
|
|
399
|
+
// Copy / Export from Modal
|
|
400
|
+
// ============================================================================
|
|
401
|
+
|
|
402
|
+
function copyMemoryToClipboard() {
|
|
403
|
+
if (!currentMemoryDetail) return;
|
|
404
|
+
var text = currentMemoryDetail.content || currentMemoryDetail.summary || '';
|
|
405
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
406
|
+
showToast('Copied to clipboard');
|
|
407
|
+
}).catch(function() {
|
|
408
|
+
// Fallback for older browsers
|
|
409
|
+
var ta = document.createElement('textarea');
|
|
410
|
+
ta.value = text;
|
|
411
|
+
document.body.appendChild(ta);
|
|
412
|
+
ta.select();
|
|
413
|
+
document.execCommand('copy');
|
|
414
|
+
document.body.removeChild(ta);
|
|
415
|
+
showToast('Copied to clipboard');
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function exportMemoryAsMarkdown() {
|
|
420
|
+
if (!currentMemoryDetail) return;
|
|
421
|
+
var mem = currentMemoryDetail;
|
|
422
|
+
var md = '# Memory #' + (mem.id || 'unknown') + '\n\n';
|
|
423
|
+
md += '**Category:** ' + (mem.category || 'None') + ' \n';
|
|
424
|
+
md += '**Project:** ' + (mem.project_name || '-') + ' \n';
|
|
425
|
+
md += '**Importance:** ' + (mem.importance || 5) + '/10 \n';
|
|
426
|
+
md += '**Tags:** ' + (mem.tags || 'None') + ' \n';
|
|
427
|
+
md += '**Created:** ' + (mem.created_at || '-') + ' \n';
|
|
428
|
+
if (mem.cluster_id) md += '**Cluster:** ' + mem.cluster_id + ' \n';
|
|
429
|
+
md += '\n---\n\n';
|
|
430
|
+
md += mem.content || mem.summary || '(no content)';
|
|
431
|
+
md += '\n\n---\n*Exported from SuperLocalMemory V2*\n';
|
|
432
|
+
|
|
433
|
+
downloadFile('memory-' + (mem.id || 'export') + '.md', md, 'text/markdown');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ============================================================================
|
|
437
|
+
// Export All / Search Results
|
|
438
|
+
// ============================================================================
|
|
439
|
+
|
|
440
|
+
function exportAll(format) {
|
|
441
|
+
// Trigger browser download from the API endpoint
|
|
442
|
+
var url = '/api/export?format=' + encodeURIComponent(format);
|
|
443
|
+
var category = document.getElementById('filter-category').value;
|
|
444
|
+
var project = document.getElementById('filter-project').value;
|
|
445
|
+
if (category) url += '&category=' + encodeURIComponent(category);
|
|
446
|
+
if (project) url += '&project_name=' + encodeURIComponent(project);
|
|
447
|
+
window.location.href = url;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function exportSearchResults() {
|
|
451
|
+
if (!lastSearchResults || lastSearchResults.length === 0) {
|
|
452
|
+
showToast('No search results to export');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
var content = JSON.stringify({
|
|
456
|
+
exported_at: new Date().toISOString(),
|
|
457
|
+
query: document.getElementById('search-query').value,
|
|
458
|
+
total: lastSearchResults.length,
|
|
459
|
+
results: lastSearchResults
|
|
460
|
+
}, null, 2);
|
|
461
|
+
downloadFile('search-results-' + Date.now() + '.json', content, 'application/json');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// File Download helper
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
function downloadFile(filename, content, mimeType) {
|
|
469
|
+
var blob = new Blob([content], { type: mimeType });
|
|
470
|
+
var url = URL.createObjectURL(blob);
|
|
471
|
+
var a = document.createElement('a');
|
|
472
|
+
a.href = url;
|
|
473
|
+
a.download = filename;
|
|
474
|
+
document.body.appendChild(a);
|
|
475
|
+
a.click();
|
|
476
|
+
document.body.removeChild(a);
|
|
477
|
+
URL.revokeObjectURL(url);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ============================================================================
|
|
481
|
+
// Toast notification
|
|
482
|
+
// ============================================================================
|
|
483
|
+
|
|
484
|
+
function showToast(message) {
|
|
485
|
+
var toast = document.createElement('div');
|
|
486
|
+
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#333;color:#fff;padding:10px 20px;border-radius:8px;font-size:0.9rem;z-index:9999;opacity:0;transition:opacity 0.3s;';
|
|
487
|
+
toast.textContent = message;
|
|
488
|
+
document.body.appendChild(toast);
|
|
489
|
+
requestAnimationFrame(function() { toast.style.opacity = '1'; });
|
|
490
|
+
setTimeout(function() {
|
|
491
|
+
toast.style.opacity = '0';
|
|
492
|
+
setTimeout(function() {
|
|
493
|
+
if (toast.parentNode) document.body.removeChild(toast);
|
|
494
|
+
}, 300);
|
|
495
|
+
}, 2000);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ============================================================================
|
|
499
|
+
// Clusters
|
|
500
|
+
// ============================================================================
|
|
501
|
+
|
|
502
|
+
async function loadClusters() {
|
|
503
|
+
showLoading('clusters-list', 'Loading clusters...');
|
|
504
|
+
try {
|
|
505
|
+
var response = await fetch('/api/clusters');
|
|
506
|
+
var data = await response.json();
|
|
507
|
+
renderClusters(data.clusters);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error('Error loading clusters:', error);
|
|
510
|
+
showEmpty('clusters-list', 'collection', 'Failed to load clusters');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderClusters(clusters) {
|
|
515
|
+
var container = document.getElementById('clusters-list');
|
|
516
|
+
if (!clusters || clusters.length === 0) {
|
|
517
|
+
showEmpty('clusters-list', 'collection', 'No clusters found. Run "slm build-graph" to generate clusters.');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
var colors = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a'];
|
|
521
|
+
|
|
522
|
+
// All dynamic values escaped — safe for innerHTML
|
|
523
|
+
var html = '';
|
|
524
|
+
clusters.forEach(function(cluster, idx) {
|
|
525
|
+
var color = colors[idx % colors.length];
|
|
526
|
+
html += '<div class="card cluster-card" style="border-color: ' + color + '">'
|
|
527
|
+
+ '<div class="card-body">'
|
|
528
|
+
+ '<h6 class="card-title">Cluster ' + escapeHtml(String(cluster.cluster_id))
|
|
529
|
+
+ ' <span class="badge bg-secondary float-end">' + escapeHtml(String(cluster.member_count)) + ' memories</span></h6>'
|
|
530
|
+
+ '<p class="mb-2"><strong>Avg Importance:</strong> ' + escapeHtml(parseFloat(cluster.avg_importance).toFixed(1)) + '</p>'
|
|
531
|
+
+ '<p class="mb-2"><strong>Categories:</strong> ' + escapeHtml(cluster.categories || 'None') + '</p>'
|
|
532
|
+
+ '<div><strong>Top Entities:</strong><br/>';
|
|
533
|
+
if (cluster.top_entities && cluster.top_entities.length > 0) {
|
|
534
|
+
cluster.top_entities.forEach(function(e) {
|
|
535
|
+
html += '<span class="badge bg-info entity-badge">' + escapeHtml(e.entity) + ' (' + escapeHtml(String(e.count)) + ')</span> ';
|
|
536
|
+
});
|
|
537
|
+
} else {
|
|
538
|
+
html += '<span class="text-muted">No entities</span>';
|
|
539
|
+
}
|
|
540
|
+
html += '</div></div></div>';
|
|
541
|
+
});
|
|
542
|
+
container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ============================================================================
|
|
546
|
+
// Patterns
|
|
547
|
+
// ============================================================================
|
|
548
|
+
|
|
549
|
+
async function loadPatterns() {
|
|
550
|
+
showLoading('patterns-list', 'Loading patterns...');
|
|
551
|
+
try {
|
|
552
|
+
var response = await fetch('/api/patterns');
|
|
553
|
+
var data = await response.json();
|
|
554
|
+
renderPatterns(data.patterns);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.error('Error loading patterns:', error);
|
|
557
|
+
showEmpty('patterns-list', 'puzzle', 'Failed to load patterns');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function renderPatterns(patterns) {
|
|
562
|
+
var container = document.getElementById('patterns-list');
|
|
563
|
+
if (!patterns || Object.keys(patterns).length === 0) {
|
|
564
|
+
showEmpty('patterns-list', 'puzzle', 'No patterns learned yet. Use SuperLocalMemory for a while to build patterns.');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// All dynamic values escaped — safe for innerHTML
|
|
569
|
+
var html = '';
|
|
570
|
+
for (var type in patterns) {
|
|
571
|
+
if (!patterns.hasOwnProperty(type)) continue;
|
|
572
|
+
var items = patterns[type];
|
|
573
|
+
html += '<h6 class="mt-3 text-capitalize">' + escapeHtml(type.replace(/_/g, ' ')) + '</h6><div class="list-group mb-3">';
|
|
574
|
+
items.forEach(function(pattern) {
|
|
575
|
+
var confidence = (pattern.confidence * 100).toFixed(0);
|
|
576
|
+
html += '<div class="list-group-item">'
|
|
577
|
+
+ '<div class="d-flex justify-content-between align-items-center">'
|
|
578
|
+
+ '<strong>' + escapeHtml(pattern.key) + '</strong>'
|
|
579
|
+
+ '<span class="badge bg-success">' + escapeHtml(confidence) + '% confidence</span>'
|
|
580
|
+
+ '</div>'
|
|
581
|
+
+ '<div class="mt-1"><small class="text-muted">' + escapeHtml(JSON.stringify(pattern.value)) + '</small></div>'
|
|
582
|
+
+ '<small class="text-muted">Evidence: ' + escapeHtml(String(pattern.evidence_count)) + ' memories</small>'
|
|
583
|
+
+ '</div>';
|
|
584
|
+
});
|
|
585
|
+
html += '</div>';
|
|
586
|
+
}
|
|
587
|
+
container.innerHTML = html; // nosemgrep: innerHTML-xss — all values escaped
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ============================================================================
|
|
591
|
+
// Timeline
|
|
592
|
+
// ============================================================================
|
|
593
|
+
|
|
594
|
+
async function loadTimeline() {
|
|
595
|
+
showLoading('timeline-chart', 'Loading timeline...');
|
|
596
|
+
try {
|
|
597
|
+
var response = await fetch('/api/timeline?days=30');
|
|
598
|
+
var data = await response.json();
|
|
599
|
+
renderTimeline(data.timeline);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
console.error('Error loading timeline:', error);
|
|
602
|
+
showEmpty('timeline-chart', 'clock-history', 'Failed to load timeline');
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function renderTimeline(timeline) {
|
|
607
|
+
var container = document.getElementById('timeline-chart');
|
|
608
|
+
if (!timeline || timeline.length === 0) {
|
|
609
|
+
showEmpty('timeline-chart', 'clock-history', 'No timeline data for the last 30 days.');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
var margin = { top: 20, right: 20, bottom: 50, left: 50 };
|
|
613
|
+
var width = container.clientWidth - margin.left - margin.right;
|
|
614
|
+
var height = 300 - margin.top - margin.bottom;
|
|
615
|
+
container.textContent = '';
|
|
616
|
+
var svg = d3.select('#timeline-chart').append('svg').attr('width', width + margin.left + margin.right).attr('height', height + margin.top + margin.bottom).append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
|
617
|
+
var x = d3.scaleBand().range([0, width]).domain(timeline.map(function(d) { return d.date || d.period; })).padding(0.1);
|
|
618
|
+
var y = d3.scaleLinear().range([height, 0]).domain([0, d3.max(timeline, function(d) { return d.count; })]);
|
|
619
|
+
svg.append('g').attr('transform', 'translate(0,' + height + ')').call(d3.axisBottom(x)).selectAll('text').attr('transform', 'rotate(-45)').style('text-anchor', 'end');
|
|
620
|
+
svg.append('g').call(d3.axisLeft(y));
|
|
621
|
+
svg.selectAll('.bar').data(timeline).enter().append('rect').attr('class', 'bar').attr('x', function(d) { return x(d.date || d.period); }).attr('y', function(d) { return y(d.count); }).attr('width', x.bandwidth()).attr('height', function(d) { return height - y(d.count); }).attr('fill', '#667eea').attr('rx', 3);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ============================================================================
|
|
625
|
+
// Date Formatters
|
|
626
|
+
// ============================================================================
|
|
627
|
+
|
|
628
|
+
function formatDate(dateString) {
|
|
629
|
+
if (!dateString) return '-';
|
|
630
|
+
var date = new Date(dateString);
|
|
631
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function formatDateFull(dateString) {
|
|
635
|
+
if (!dateString) return '-';
|
|
636
|
+
var date = new Date(dateString);
|
|
637
|
+
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ============================================================================
|
|
641
|
+
// Event Listeners
|
|
642
|
+
// ============================================================================
|
|
643
|
+
|
|
644
|
+
document.getElementById('memories-tab').addEventListener('shown.bs.tab', loadMemories);
|
|
645
|
+
document.getElementById('clusters-tab').addEventListener('shown.bs.tab', loadClusters);
|
|
646
|
+
document.getElementById('patterns-tab').addEventListener('shown.bs.tab', loadPatterns);
|
|
647
|
+
document.getElementById('timeline-tab').addEventListener('shown.bs.tab', loadTimeline);
|
|
648
|
+
document.getElementById('search-query').addEventListener('keypress', function(e) { if (e.key === 'Enter') searchMemories(); });
|
|
649
|
+
|
|
650
|
+
window.addEventListener('DOMContentLoaded', function() {
|
|
651
|
+
initDarkMode();
|
|
652
|
+
loadStats();
|
|
653
|
+
loadGraph();
|
|
654
|
+
});
|