1pub const DASHBOARD_HTML: &str = r#"<!DOCTYPE html>
5<html lang="en">
6<head>
7 <meta charset="UTF-8">
8 <meta name="viewport" content="width=device-width, initial-scale=1.0">
9 <title>mneme — Memory Dashboard</title>
10 <link rel="icon" type="image/svg+xml" href="/favicon.ico">
11 <style>
12 * { box-sizing: border-box; margin: 0; padding: 0; }
13 body {
14 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
15 background: #0d1117;
16 color: #c9d1d9;
17 padding: 24px;
18 line-height: 1.5;
19 }
20 .container { max-width: 1400px; margin: 0 auto; }
21 h1 { color: #58a6ff; margin-bottom: 4px; font-size: 28px; }
22 h2 { color: #f0f6fc; font-size: 13px; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
23 h3 { color: #f0f6fc; font-size: 12px; margin: 14px 0 6px 0; }
24 .subtitle { color: #8b949e; margin-bottom: 20px; font-size: 13px; }
25
26 .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
27 .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; }
28 .stat-card .value { font-size: 26px; font-weight: 700; color: #f0f6fc; }
29 .stat-card .label { color: #8b949e; font-size: 11px; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
30 .stat-card .delta { font-size: 11px; color: #7ee787; margin-top: 4px; }
31 .stat-card .delta.negative { color: #ff7b72; }
32
33 .grid-2col { display: grid; grid-template-columns: 320px 1fr; gap: 16px; margin-bottom: 16px; }
34 @media (max-width: 900px) { .grid-2col { grid-template-columns: 1fr; } }
35
36 .panel { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
37 .panel h2 { margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #21262d; }
38
39 .filter-group { margin-bottom: 14px; }
40 .filter-group label { display: block; color: #8b949e; font-size: 11px; margin-bottom: 6px; text-transform: uppercase; }
41 .filter-chip {
42 display: inline-block; padding: 4px 10px; margin: 2px;
43 background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
44 border-radius: 14px; font-size: 11px; cursor: pointer; user-select: none;
45 transition: all 0.1s;
46 }
47 .filter-chip:hover { border-color: #58a6ff; }
48 .filter-chip.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
49 .filter-chip .count { color: #8b949e; font-size: 10px; margin-left: 4px; }
50 .filter-chip.active .count { color: rgba(255,255,255,0.7); }
51
52 input[type="text"], select {
53 width: 100%; padding: 8px 10px;
54 background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
55 border-radius: 6px; font-size: 13px; outline: none;
56 }
57 input[type="text"]:focus, select:focus { border-color: #58a6ff; }
58 input::placeholder { color: #6e7681; }
59
60 button {
61 padding: 8px 14px; background: #238636; border: 0; color: #fff;
62 border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
63 }
64 button:hover { background: #2ea043; }
65 button.secondary { background: #21262d; color: #c9d1d9; }
66 button.secondary:hover { background: #30363d; }
67
68 .memory-list { max-height: 600px; overflow-y: auto; }
69 .memory-item {
70 background: #0d1117; border: 1px solid #21262d; border-radius: 6px;
71 padding: 12px 14px; margin-bottom: 8px; cursor: pointer;
72 transition: border-color 0.1s;
73 }
74 .memory-item:hover { border-color: #58a6ff; }
75 .memory-item.selected { border-color: #1f6feb; background: #0d1929; }
76 .memory-title { color: #f0f6fc; font-size: 14px; margin-bottom: 6px; }
77 .memory-content {
78 color: #8b949e; font-size: 12px; line-height: 1.4;
79 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
80 overflow: hidden;
81 }
82 .memory-meta { color: #6e7681; font-size: 10px; margin-top: 6px; display: flex; gap: 8px; align-items: center; }
83
84 .pill { display: inline-block; padding: 2px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; }
85 .pill-critical { background: #da3633; color: #fff; }
86 .pill-high { background: #d29922; color: #fff; }
87 .pill-medium { background: #6e7681; color: #fff; }
88 .pill-low { background: #21262d; color: #8b949e; }
89 .tag { display: inline-block; background: #1f6feb33; color: #79c0ff; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 3px; }
90 .type-pill { display: inline-block; background: #21262d; color: #c9d1d9; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 500; }
91
92 .highlight { background: #f0f6fc; color: #0d1117; padding: 0 2px; border-radius: 2px; font-weight: 600; }
93
94 .empty { color: #6e7681; font-style: italic; padding: 20px; text-align: center; }
95
96 .detail-panel { max-height: 700px; overflow-y: auto; }
97 .detail-row { margin-bottom: 8px; }
98 .detail-label { color: #8b949e; font-size: 11px; text-transform: uppercase; margin-bottom: 2px; }
99 .detail-value { color: #f0f6fc; font-size: 13px; word-break: break-word; }
100
101 .entity-link { color: #79c0ff; cursor: pointer; }
102 .entity-link:hover { text-decoration: underline; }
103
104 .pagination { display: flex; gap: 8px; margin-top: 12px; align-items: center; }
105 .pagination button { padding: 6px 12px; font-size: 12px; }
106 .pagination .page-info { color: #8b949e; font-size: 12px; }
107
108 .footer { color: #6e7681; font-size: 11px; text-align: center; margin-top: 32px; padding-top: 16px; border-top: 1px solid #21262d; }
109 .footer a { color: #58a6ff; text-decoration: none; }
110
111 .tabs { display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid #21262d; }
112 .tab { padding: 8px 14px; color: #8b949e; cursor: pointer; border-bottom: 2px solid transparent; font-size: 12px; }
113 .tab:hover { color: #c9d1d9; }
114 .tab.active { color: #f0f6fc; border-bottom-color: #58a6ff; }
115
116 .skeleton { background: linear-gradient(90deg, #161b22 0%, #21262d 50%, #161b22 100%); background-size: 200% 100%; animation: pulse 1.5s infinite; border-radius: 4px; height: 14px; margin-bottom: 6px; }
117 @keyframes pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
118
119 .toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
120 .toolbar input { flex: 1; }
121 </style>
122</head>
123<body>
124 <div class="container">
125 <h1>🧠 mneme</h1>
126 <p class="subtitle">Persistent memory for AI agents · v0.1.0 · <span id="project-name">__DEFAULT_PROJECT__</span></p>
127
128 <!-- Stats overview -->
129 <div class="stats-grid" id="stats-grid">
130 <div class="stat-card"><div class="value" id="stat-total">—</div><div class="label">Total memorias</div></div>
131 <div class="stat-card"><div class="value" id="stat-relations">—</div><div class="label">Relaciones</div></div>
132 <div class="stat-card"><div class="value" id="stat-sessions">—</div><div class="label">Sesiones</div></div>
133 <div class="stat-card"><div class="value" id="stat-conflicts">—</div><div class="label">Conflictos pendientes</div></div>
134 <div class="stat-card"><div class="value" id="stat-entities">—</div><div class="label">Entidades</div></div>
135 </div>
136
137 <!-- Two-column layout: filters | content -->
138 <div class="grid-2col">
139 <!-- Filters panel -->
140 <div class="panel">
141 <h2>🎛 Filtros</h2>
142
143 <div class="filter-group">
144 <label>Buscar</label>
145 <input type="text" id="search-input" placeholder="Buscar en title, content, tags...">
146 </div>
147
148 <div class="filter-group">
149 <label>Tipo de memoria</label>
150 <div id="filter-types" class="filter-chips">
151 <!-- populated by JS -->
152 </div>
153 </div>
154
155 <div class="filter-group">
156 <label>Importancia</label>
157 <div id="filter-importance" class="filter-chips">
158 <div class="filter-chip" data-imp="low">low <span class="count">·</span></div>
159 <div class="filter-chip" data-imp="medium">medium <span class="count">·</span></div>
160 <div class="filter-chip" data-imp="high">high <span class="count">·</span></div>
161 <div class="filter-chip" data-imp="critical">critical <span class="count">·</span></div>
162 </div>
163 </div>
164
165 <div class="filter-group">
166 <label>Tags</label>
167 <div id="filter-tags" class="filter-chips">
168 <!-- populated by JS -->
169 </div>
170 </div>
171
172 <div class="filter-group">
173 <label>Ordenar por</label>
174 <select id="sort-select">
175 <option value="updated_desc">Recientes primero</option>
176 <option value="updated_asc">Antiguas primero</option>
177 <option value="importance_desc">Más importantes</option>
178 <option value="access_desc">Más accedidas</option>
179 <option value="title_asc">Título (A-Z)</option>
180 </select>
181 </div>
182
183 <button class="secondary" onclick="resetFilters()" style="width:100%;">Limpiar filtros</button>
184 </div>
185
186 <!-- Memories panel -->
187 <div class="panel">
188 <div class="tabs">
189 <div class="tab active" data-tab="all">Todas</div>
190 <div class="tab" data-tab="entities">Entidades</div>
191 <div class="tab" data-tab="temporal">Temporal</div>
192 </div>
193
194 <div id="tab-content-all">
195 <div class="toolbar">
196 <span id="results-count" style="color:#8b949e; font-size:12px; align-self:center;">—</span>
197 </div>
198 <div class="memory-list" id="memories-list">
199 <div class="empty">Cargando memorias...</div>
200 </div>
201 <div class="pagination">
202 <button onclick="prevPage()" class="secondary">← Anterior</button>
203 <span class="page-info" id="page-info">—</span>
204 <button onclick="nextPage()" class="secondary">Siguiente →</button>
205 </div>
206 </div>
207
208 <div id="tab-content-entities" style="display:none;">
209 <h3>Entidades más frecuentes</h3>
210 <div id="entities-list"></div>
211 </div>
212
213 <div id="tab-content-temporal" style="display:none;">
214 <h3>Validez temporal</h3>
215 <div id="temporal-stats"></div>
216 <h3 style="margin-top:16px;">Memorias con ventana de validez</h3>
217 <div id="temporal-memories"></div>
218 </div>
219 </div>
220 </div>
221
222 <!-- Memory detail panel -->
223 <div class="panel" id="detail-panel" style="display:none; margin-top:16px;">
224 <h2 id="detail-title">Memoria</h2>
225 <div class="detail-panel" id="detail-content"></div>
226 </div>
227
228 <p class="footer">
229 mneme v0.1.0 ·
230 <a href="https://github.com/daddydiaz2/mneme" target="_blank">GitHub</a> ·
231 <a href="/api/v1/projects">API</a> ·
232 64 MCP tools disponibles
233 </p>
234 </div>
235
236 <script>
237 const API = '';
238 const PROJECT = '__DEFAULT_PROJECT__';
239 const PAGE_SIZE = 20;
240 let currentPage = 0;
241 let currentFilters = { search: '', types: [], importance: [], tags: [], sort: 'updated_desc' };
242 let currentTab = 'all';
243 let memoryTypes = [];
244 let importanceCounts = {};
245 let allTags = [];
246
247 // === TAB SWITCHING ===
248 document.querySelectorAll('.tab').forEach(t => {
249 t.addEventListener('click', () => switchTab(t.dataset.tab));
250 });
251 function switchTab(name) {
252 currentTab = name;
253 document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
254 document.getElementById('tab-content-all').style.display = name === 'all' ? 'block' : 'none';
255 document.getElementById('tab-content-entities').style.display = name === 'entities' ? 'block' : 'none';
256 document.getElementById('tab-content-temporal').style.display = name === 'temporal' ? 'block' : 'none';
257 if (name === 'entities') loadEntities();
258 if (name === 'temporal') loadTemporal();
259 }
260
261 // === STATS ===
262 async function loadStats() {
263 try {
264 const [stats, projects, peers, conflictResp] = await Promise.all([
265 fetch(`${API}/api/v1/stats?project=${PROJECT}`).then(r => r.json()).catch(() => null),
266 fetch(`${API}/api/v1/projects`).then(r => r.json()).catch(() => []),
267 fetch(`${API}/api/v1/cloud/status?project=${PROJECT}`).then(r => r.json()).catch(() => null)
268 ]);
269
270 if (stats) {
271 document.getElementById('stat-total').textContent = stats.total_memories ?? '0';
272 document.getElementById('stat-relations').textContent = stats.total_relations ?? '0';
273 document.getElementById('stat-sessions').textContent = stats.total_sessions ?? '0';
274 }
275 if (conflictResp) {
276 // Estimate pending conflicts from sync log
277 const conflicts = (conflictResp.recent_syncs || []).filter(s => s.status === 'partial').length;
278 document.getElementById('stat-conflicts').textContent = conflicts;
279 } else {
280 document.getElementById('stat-conflicts').textContent = '0';
281 }
282 // Entities count: get from /api/v1/memories search results
283 loadEntityCount();
284 } catch (e) {
285 console.error('loadStats failed', e);
286 }
287 }
288
289 async function loadEntityCount() {
290 try {
291 const r = await fetch(`${API}/api/v1/memories?project=${PROJECT}&limit=100`);
292 const j = await r.json();
293 if (Array.isArray(j)) {
294 const entities = new Set();
295 j.forEach(m => (m.tags || []).forEach(t => entities.add(t)));
296 document.getElementById('stat-entities').textContent = entities.size;
297 }
298 } catch (e) {
299 document.getElementById('stat-entities').textContent = '?';
300 }
301 }
302
303 // === FILTERS LOADING ===
304 async function loadFilterOptions() {
305 try {
306 const memories = await fetch(`${API}/api/v1/memories?project=${PROJECT}&limit=500`).then(r => r.json()).catch(() => []);
307 if (!Array.isArray(memories)) return;
308
309 memoryTypes = {};
310 importanceCounts = { low: 0, medium: 0, high: 0, critical: 0 };
311 allTags = new Map();
312 memories.forEach(m => {
313 memoryTypes[m.memory_type] = (memoryTypes[m.memory_type] || 0) + 1;
314 importanceCounts[m.importance] = (importanceCounts[m.importance] || 0) + 1;
315 (m.tags || []).forEach(t => allTags.set(t, (allTags.get(t) || 0) + 1));
316 });
317
318 // Render type chips
319 const typesEl = document.getElementById('filter-types');
320 typesEl.innerHTML = Object.entries(memoryTypes).sort((a, b) => b[1] - a[1]).map(([t, c]) =>
321 `<div class="filter-chip" data-type="${t}">${t} <span class="count">${c}</span></div>`
322 ).join('') || '<span class="empty" style="padding:0;">No hay memorias</span>';
323
324 // Render importance counts
325 document.querySelectorAll('#filter-importance .filter-chip').forEach(chip => {
326 const imp = chip.dataset.imp;
327 chip.querySelector('.count').textContent = `· ${importanceCounts[imp] || 0}`;
328 });
329
330 // Render top tags
331 const tagsEl = document.getElementById('filter-tags');
332 const topTags = [...allTags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30);
333 tagsEl.innerHTML = topTags.map(([t, c]) =>
334 `<div class="filter-chip" data-tag="${t}">${t} <span class="count">${c}</span></div>`
335 ).join('') || '<span class="empty" style="padding:0;">Sin tags</span>';
336
337 // Attach filter handlers
338 document.querySelectorAll('.filter-chip').forEach(chip => {
339 chip.addEventListener('click', () => toggleFilter(chip));
340 });
341 } catch (e) {
342 console.error('loadFilterOptions failed', e);
343 }
344 }
345
346 function toggleFilter(chip) {
347 const t = chip.dataset.type;
348 const i = chip.dataset.imp;
349 const tg = chip.dataset.tag;
350 if (t) {
351 if (currentFilters.types.includes(t)) {
352 currentFilters.types = currentFilters.types.filter(x => x !== t);
353 chip.classList.remove('active');
354 } else {
355 currentFilters.types.push(t);
356 chip.classList.add('active');
357 }
358 } else if (i) {
359 if (currentFilters.importance.includes(i)) {
360 currentFilters.importance = currentFilters.importance.filter(x => x !== i);
361 chip.classList.remove('active');
362 } else {
363 currentFilters.importance.push(i);
364 chip.classList.add('active');
365 }
366 } else if (tg) {
367 if (currentFilters.tags.includes(tg)) {
368 currentFilters.tags = currentFilters.tags.filter(x => x !== tg);
369 chip.classList.remove('active');
370 } else {
371 currentFilters.tags.push(tg);
372 chip.classList.add('active');
373 }
374 }
375 currentPage = 0;
376 loadMemories();
377 }
378
379 // === SEARCH with debounce + highlighting ===
380 let searchTimer = null;
381 document.getElementById('search-input').addEventListener('input', (e) => {
382 currentFilters.search = e.target.value;
383 currentPage = 0;
384 clearTimeout(searchTimer);
385 searchTimer = setTimeout(() => loadMemories(), 200);
386 });
387 document.getElementById('sort-select').addEventListener('change', (e) => {
388 currentFilters.sort = e.target.value;
389 loadMemories();
390 });
391
392 function resetFilters() {
393 currentFilters = { search: '', types: [], importance: [], tags: [], sort: 'updated_desc' };
394 document.getElementById('search-input').value = '';
395 document.getElementById('sort-select').value = 'updated_desc';
396 document.querySelectorAll('.filter-chip.active').forEach(c => c.classList.remove('active'));
397 currentPage = 0;
398 loadMemories();
399 }
400
401 // === MEMORIES LIST ===
402 async function loadMemories() {
403 const listEl = document.getElementById('memories-list');
404 const isSearch = currentFilters.search || currentFilters.types.length || currentFilters.importance.length || currentFilters.tags.length;
405
406 if (!isSearch && !currentFilters.sort.includes('importance') && !currentFilters.sort.includes('access') && !currentFilters.sort.includes('title')) {
407 // Use simple list endpoint
408 listEl.innerHTML = '<div class="skeleton" style="width:80%"></div><div class="skeleton" style="width:60%"></div><div class="skeleton" style="width:70%"></div>';
409 try {
410 const url = new URL(`${API}/api/v1/memories`, window.location.origin);
411 url.searchParams.set('project', PROJECT);
412 url.searchParams.set('limit', String(PAGE_SIZE));
413 url.searchParams.set('offset', String(currentPage * PAGE_SIZE));
414 if (currentFilters.types.length === 1) url.searchParams.set('type', currentFilters.types[0]);
415 if (currentFilters.importance.length === 1) url.searchParams.set('importance', currentFilters.importance[0]);
416 const r = await fetch(url);
417 const j = await r.json();
418 renderMemories(Array.isArray(j) ? j : []);
419 } catch (e) {
420 listEl.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
421 }
422 } else {
423 // Use search endpoint
424 listEl.innerHTML = '<div class="skeleton" style="width:80%"></div><div class="skeleton" style="width:60%"></div>';
425 try {
426 const r = await fetch(`${API}/api/v1/memories/search`, {
427 method: 'POST',
428 headers: {'Content-Type': 'application/json'},
429 body: JSON.stringify({
430 text: currentFilters.search || '',
431 project: PROJECT,
432 limit: PAGE_SIZE
433 })
434 });
435 const j = await r.json();
436 // /search returns {results: [...]} or directly [...]
437 const results = Array.isArray(j) ? j : (j.results || []);
438 renderMemories(results.map(s => ({ ...s.memory, _score: s.score, _snippet: s.snippet })));
439 } catch (e) {
440 listEl.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
441 }
442 }
443 }
444
445 function renderMemories(memories) {
446 const listEl = document.getElementById('memories-list');
447 document.getElementById('results-count').textContent = `${memories.length} resultado${memories.length === 1 ? '' : 's'}`;
448 document.getElementById('page-info').textContent = `Página ${currentPage + 1}`;
449
450 if (memories.length === 0) {
451 listEl.innerHTML = '<div class="empty">No se encontraron memorias con los filtros actuales.</div>';
452 return;
453 }
454
455 listEl.innerHTML = memories.map(m => {
456 const imp = m.importance || 'medium';
457 const impPill = `<span class="pill pill-${imp}">${imp.toUpperCase()}</span>`;
458 const typePill = `<span class="type-pill">${m.memory_type || 'note'}</span>`;
459 const tags = (m.tags || []).slice(0, 5).map(t => `<span class="tag">#${escapeHtml(t)}</span>`).join('');
460 const moreTags = (m.tags || []).length > 5 ? `<span class="tag">+${(m.tags || []).length - 5}</span>` : '';
461 const date = m.updated_at ? new Date(m.updated_at).toLocaleString() : '';
462 const score = m._score ? `<span class="tag" style="background:#238636; color:#fff;">score: ${m._score.toFixed(2)}</span>` : '';
463 const snippet = m._snippet ? `<div class="memory-content">${highlightTerms(m._snippet)}</div>` :
464 `<div class="memory-content">${highlightTerms(m.content || '').substring(0, 200)}</div>`;
465 return `<div class="memory-item" onclick="showDetail('${m.id}')">
466 <div class="memory-title">${typePill} ${impPill} ${score} ${escapeHtml(m.title || 'untitled')}</div>
467 ${snippet}
468 <div class="memory-meta">${tags}${moreTags}<span style="margin-left:auto;">${date}</span></div>
469 </div>`;
470 }).join('');
471 }
472
473 function highlightTerms(text) {
474 if (!currentFilters.search) return escapeHtml(text);
475 const terms = currentFilters.search.split(/\s+/).filter(t => t.length > 2);
476 let result = escapeHtml(text);
477 terms.forEach(term => {
478 const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
479 result = result.replace(regex, '<span class="highlight">$1</span>');
480 });
481 return result;
482 }
483
484 function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
485
486 function prevPage() { if (currentPage > 0) { currentPage--; loadMemories(); } }
487 function nextPage() { currentPage++; loadMemories(); }
488
489 // === MEMORY DETAIL ===
490 async function showDetail(id) {
491 const panel = document.getElementById('detail-panel');
492 const content = document.getElementById('detail-content');
493 panel.style.display = 'block';
494 content.innerHTML = '<div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div>';
495 panel.scrollIntoView({ behavior: 'smooth' });
496 try {
497 const [memResp, entitiesResp] = await Promise.all([
498 fetch(`${API}/api/v1/memories/${id}`).then(r => r.json()),
499 fetch(`${API}/api/v1/memories/${id}/entities`).then(r => r.ok ? r.json() : []).catch(() => [])
500 ]);
501 if (!memResp || memResp.error) {
502 content.innerHTML = `<div class="empty">No se pudo cargar la memoria.</div>`;
503 return;
504 }
505 document.getElementById('detail-title').textContent = memResp.title || 'untitled';
506 const m = memResp;
507 const what = m.what ? `<div class="detail-row"><div class="detail-label">What</div><div class="detail-value">${escapeHtml(m.what)}</div></div>` : '';
508 const why = m.why ? `<div class="detail-row"><div class="detail-label">Why</div><div class="detail-value">${escapeHtml(m.why)}</div></div>` : '';
509 const ctx = m.context ? `<div class="detail-row"><div class="detail-label">Context</div><div class="detail-value">${escapeHtml(m.context)}</div></div>` : '';
510 const learned = m.learned ? `<div class="detail-row"><div class="detail-label">Learned</div><div class="detail-value">${escapeHtml(m.learned)}</div></div>` : '';
511 const tags = (m.tags || []).map(t => `<span class="tag">#${escapeHtml(t)}</span>`).join('') || '<span class="empty">No tags</span>';
512 const entities = (Array.isArray(entitiesResp) ? entitiesResp : []).map(e =>
513 `<span class="entity-link" onclick="searchByEntity('${escapeHtml(e.entity_name)}')">${escapeHtml(e.entity_name)} <small>(${e.entity_type})</small></span>`
514 ).join(' | ') || '<span class="empty">No entities</span>';
515 const valid = (m.valid_from || m.valid_until) ?
516 `<div class="detail-row"><div class="detail-label">Validez temporal</div><div class="detail-value">${m.valid_from || '∞'} → ${m.valid_until || '∞'}</div></div>` : '';
517
518 content.innerHTML = `
519 <div class="detail-row"><div class="detail-label">Tipo / Importancia</div><div class="detail-value"><span class="type-pill">${m.memory_type}</span> <span class="pill pill-${m.importance}">${m.importance.toUpperCase()}</span></div></div>
520 <div class="detail-row"><div class="detail-label">Contenido</div><div class="detail-value" style="white-space:pre-wrap;">${escapeHtml(m.content)}</div></div>
521 ${what}${why}${ctx}${learned}
522 <div class="detail-row"><div class="detail-label">Tags</div><div class="detail-value">${tags}</div></div>
523 <div class="detail-row"><div class="detail-label">Entidades</div><div class="detail-value">${entities}</div></div>
524 <div class="detail-row"><div class="detail-label">Accesos / Revisiones</div><div class="detail-value">${m.access_count} accesos · ${m.revision_count} revisiones · ${m.duplicate_count} duplicados</div></div>
525 <div class="detail-row"><div class="detail-label">Fechas</div><div class="detail-value">Creado: ${m.created_at ? new Date(m.created_at).toLocaleString() : '?'}<br>Actualizado: ${m.updated_at ? new Date(m.updated_at).toLocaleString() : '?'}</div></div>
526 ${valid}
527 <div class="detail-row" style="margin-top:16px;">
528 <button onclick="deleteMemory('${m.id}')" style="background:#da3633;">Eliminar</button>
529 </div>
530 `;
531 } catch (e) {
532 content.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
533 }
534 }
535
536 async function deleteMemory(id) {
537 if (!confirm('¿Eliminar esta memoria?')) return;
538 try {
539 await fetch(`${API}/api/v1/memories/${id}?hard=false`, { method: 'DELETE' });
540 document.getElementById('detail-panel').style.display = 'none';
541 loadMemories();
542 loadStats();
543 } catch (e) {
544 alert('Error: ' + e.message);
545 }
546 }
547
548 function searchByEntity(entity) {
549 currentFilters.search = entity;
550 document.getElementById('search-input').value = entity;
551 currentPage = 0;
552 switchTab('all');
553 loadMemories();
554 }
555
556 // === ENTITIES TAB ===
557 async function loadEntities() {
558 const el = document.getElementById('entities-list');
559 el.innerHTML = '<div class="skeleton"></div><div class="skeleton"></div>';
560 try {
561 const memories = await fetch(`${API}/api/v1/memories?project=${PROJECT}&limit=500`).then(r => r.json());
562 if (!Array.isArray(memories)) {
563 el.innerHTML = '<div class="empty">No se pudieron cargar entidades.</div>';
564 return;
565 }
566 const counts = new Map();
567 memories.forEach(m => (m.tags || []).forEach(t => {
568 counts.set(t, (counts.get(t) || 0) + 1);
569 }));
570 const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50);
571 el.innerHTML = sorted.map(([name, count]) =>
572 `<div class="memory-item" onclick="searchByEntity('${escapeHtml(name)}')" style="display:flex; justify-content:space-between;">
573 <span class="entity-link">${escapeHtml(name)}</span>
574 <span class="tag">${count} memorias</span>
575 </div>`
576 ).join('') || '<div class="empty">Sin entidades extraídas</div>';
577 } catch (e) {
578 el.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
579 }
580 }
581
582 // === TEMPORAL TAB ===
583 async function loadTemporal() {
584 const statsEl = document.getElementById('temporal-stats');
585 const memsEl = document.getElementById('temporal-memories');
586 statsEl.innerHTML = '<div class="skeleton"></div>';
587 memsEl.innerHTML = '';
588 try {
589 const memories = await fetch(`${API}/api/v1/memories?project=${PROJECT}&limit=500`).then(r => r.json());
590 if (!Array.isArray(memories)) {
591 statsEl.innerHTML = '<div class="empty">No se pudieron cargar memorias.</div>';
592 return;
593 }
594 const now = new Date();
595 let valid = 0, expired = 0, total_with_window = 0;
596 const temporal = memories.filter(m => m.valid_from || m.valid_until);
597 temporal.forEach(m => {
598 total_with_window++;
599 const inWindow = (!m.valid_from || new Date(m.valid_from) <= now) &&
600 (!m.valid_until || new Date(m.valid_until) > now);
601 if (inWindow) valid++; else expired++;
602 });
603 statsEl.innerHTML = `
604 <div class="stats-grid">
605 <div class="stat-card"><div class="value">${total_with_window}</div><div class="label">Con ventana</div></div>
606 <div class="stat-card"><div class="value" style="color:#7ee787;">${valid}</div><div class="label">Válidas</div></div>
607 <div class="stat-card"><div class="value" style="color:#ff7b72;">${expired}</div><div class="label">Expiradas</div></div>
608 </div>
609 `;
610 memsEl.innerHTML = temporal.slice(0, 30).map(m => {
611 const vf = m.valid_from ? new Date(m.valid_from).toLocaleDateString() : '∞';
612 const vu = m.valid_until ? new Date(m.valid_until).toLocaleDateString() : '∞';
613 return `<div class="memory-item" onclick="showDetail('${m.id}')">
614 <div class="memory-title">${escapeHtml(m.title)}</div>
615 <div class="memory-meta">${vf} → ${vu}</div>
616 </div>`;
617 }).join('') || '<div class="empty">No hay memorias con ventana de validez.</div>';
618 } catch (e) {
619 statsEl.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
620 }
621 }
622
623 // === UTILS ===
624 function escapeHtml(s) {
625 if (s == null) return '';
626 return String(s).replace(/[&<>"']/g, c => ({
627 '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
628 }[c]));
629 }
630
631 // === INIT ===
632 document.addEventListener('DOMContentLoaded', () => {
633 loadStats();
634 loadFilterOptions().then(loadMemories);
635 setInterval(loadStats, 30000);
636 });
637 </script>
638</body>
639</html>"#;
640
641pub fn render_dashboard(default_project: &str) -> String {
643 DASHBOARD_HTML.replace("__DEFAULT_PROJECT__", default_project)
644}