personal memory agent
1<div class="workspace-content" style="display:flex;flex-direction:column;height:calc(100vh - var(--facet-bar-height) - var(--app-bar-height) - 24px);overflow:hidden;">
2 <!-- Filter controls -->
3 <div class="graph-controls" id="graph-controls" style="display:none;">
4 <div class="graph-controls-row">
5 <div class="graph-type-filters">
6 <button class="graph-type-btn active" data-type="person" style="--btn-color:#3b82f6">Person</button>
7 <button class="graph-type-btn active" data-type="company" style="--btn-color:#22c55e">Company</button>
8 <button class="graph-type-btn active" data-type="project" style="--btn-color:#f59e0b">Project</button>
9 <button class="graph-type-btn active" data-type="tool" style="--btn-color:#6b7280">Tool</button>
10 </div>
11 <div class="graph-time-filters">
12 <button class="graph-time-btn" data-days="7">7d</button>
13 <button class="graph-time-btn" data-days="30">30d</button>
14 <button class="graph-time-btn active" data-days="90">90d</button>
15 <button class="graph-time-btn" data-days="">All</button>
16 </div>
17 <div class="graph-strength-filter">
18 <label for="min-strength">Min strength</label>
19 <input type="range" id="min-strength" min="0" max="500" value="0" step="5">
20 <span id="min-strength-val">0</span>
21 </div>
22 <div class="graph-stats" id="graph-stats"></div>
23 </div>
24 </div>
25
26 <!-- Graph container -->
27 <div id="graph-container" style="flex:1;position:relative;min-height:0;">
28 <div class="graph-loading" id="graph-loading">Loading knowledge graph...</div>
29 <div class="graph-empty" id="graph-empty" style="display:none;">
30 <div class="graph-empty-icon">🕸️</div>
31 <h2>Your knowledge graph builds itself from daily use</h2>
32 <p>As solstone captures your meetings, conversations, and work, entities and relationships appear here automatically.</p>
33 </div>
34 <div id="graph-canvas" style="width:100%;height:100%;display:none;"></div>
35 </div>
36
37 <!-- Entity detail panel (slide-in from right) -->
38 <div class="graph-detail-panel" id="graph-detail-panel">
39 <div class="graph-detail-header">
40 <h2 id="detail-name"></h2>
41 <button class="graph-detail-close" id="detail-close">×</button>
42 </div>
43 <div class="graph-detail-body" id="detail-body"></div>
44 </div>
45</div>
46
47<style>
48/* Controls bar */
49.graph-controls {
50 padding: 0.5rem 0.75rem;
51 border-bottom: 1px solid #e5e7eb;
52 background: #fafafa;
53 flex-shrink: 0;
54}
55.graph-controls-row {
56 display: flex;
57 align-items: center;
58 gap: 1rem;
59 flex-wrap: wrap;
60}
61.graph-type-filters, .graph-time-filters {
62 display: flex;
63 gap: 0.25rem;
64}
65.graph-type-btn, .graph-time-btn {
66 padding: 0.25rem 0.6rem;
67 border: 1px solid #d1d5db;
68 border-radius: 4px;
69 background: white;
70 font-size: 0.8rem;
71 cursor: pointer;
72 transition: all 0.15s;
73 color: #374151;
74}
75.graph-type-btn.active {
76 background: var(--btn-color, #3b82f6);
77 color: white;
78 border-color: var(--btn-color, #3b82f6);
79}
80.graph-time-btn.active {
81 background: #374151;
82 color: white;
83 border-color: #374151;
84}
85.graph-type-btn:hover, .graph-time-btn:hover {
86 opacity: 0.85;
87}
88.graph-strength-filter {
89 display: flex;
90 align-items: center;
91 gap: 0.4rem;
92 font-size: 0.8rem;
93 color: #6b7280;
94}
95.graph-strength-filter input[type=range] {
96 width: 80px;
97 height: 4px;
98}
99.graph-stats {
100 margin-left: auto;
101 font-size: 0.8rem;
102 color: #6b7280;
103}
104
105/* Loading / empty states */
106.graph-loading {
107 text-align: center;
108 padding: 4em;
109 color: #666;
110}
111.graph-empty {
112 text-align: center;
113 padding: 4em 2em;
114 max-width: 450px;
115 margin: 2em auto;
116}
117.graph-empty-icon {
118 font-size: 4em;
119 margin-bottom: 0.25em;
120}
121.graph-empty h2 {
122 margin: 0 0 0.5em 0;
123 font-size: 1.3em;
124 font-weight: 600;
125 color: #333;
126}
127.graph-empty p {
128 margin: 0;
129 color: #666;
130 line-height: 1.5;
131}
132
133/* Detail panel */
134.graph-detail-panel {
135 position: absolute;
136 top: 0;
137 right: 0;
138 width: 340px;
139 max-width: 90vw;
140 height: 100%;
141 background: white;
142 box-shadow: -2px 0 12px rgba(0,0,0,0.12);
143 z-index: 20;
144 transform: translateX(100%);
145 transition: transform 0.2s ease;
146 display: flex;
147 flex-direction: column;
148 overflow: hidden;
149}
150.graph-detail-panel.open {
151 transform: translateX(0);
152}
153.graph-detail-header {
154 display: flex;
155 align-items: center;
156 justify-content: space-between;
157 padding: 0.75rem 1rem;
158 border-bottom: 1px solid #e5e7eb;
159 flex-shrink: 0;
160}
161.graph-detail-header h2 {
162 margin: 0;
163 font-size: 1.1rem;
164 font-weight: 600;
165 color: #111827;
166 overflow: hidden;
167 text-overflow: ellipsis;
168 white-space: nowrap;
169}
170.graph-detail-close {
171 background: none;
172 border: none;
173 font-size: 1.5rem;
174 color: #6b7280;
175 cursor: pointer;
176 padding: 0 0.25rem;
177 line-height: 1;
178}
179.graph-detail-close:hover {
180 color: #111827;
181}
182.graph-detail-body {
183 flex: 1;
184 overflow-y: auto;
185 padding: 0.75rem 1rem;
186 font-size: 0.9rem;
187 color: #374151;
188}
189.detail-section {
190 margin-bottom: 1rem;
191}
192.detail-section-title {
193 font-size: 0.7rem;
194 font-weight: 600;
195 text-transform: uppercase;
196 letter-spacing: 0.05em;
197 color: #6b7280;
198 margin: 0 0 0.4rem 0;
199}
200.detail-type-badge {
201 display: inline-block;
202 padding: 0.15rem 0.5rem;
203 border-radius: 4px;
204 font-size: 0.75rem;
205 font-weight: 500;
206 color: white;
207 margin-bottom: 0.5rem;
208}
209.detail-principal-badge {
210 display: inline-block;
211 padding: 0.15rem 0.5rem;
212 border-radius: 4px;
213 font-size: 0.75rem;
214 font-weight: 500;
215 background: #f59e0b;
216 color: white;
217 margin-left: 0.4rem;
218}
219.detail-description {
220 color: #4b5563;
221 line-height: 1.5;
222 margin-bottom: 0.75rem;
223}
224.detail-score-grid {
225 display: grid;
226 grid-template-columns: 1fr 1fr;
227 gap: 0.3rem;
228}
229.detail-score-item {
230 display: flex;
231 justify-content: space-between;
232 font-size: 0.82rem;
233}
234.detail-score-label {
235 color: #6b7280;
236}
237.detail-score-value {
238 font-weight: 600;
239 color: #111827;
240}
241.detail-connected-list {
242 list-style: none;
243 padding: 0;
244 margin: 0;
245}
246.detail-connected-item {
247 padding: 0.2rem 0;
248 border-bottom: 1px solid #f3f4f6;
249 display: flex;
250 justify-content: space-between;
251 font-size: 0.82rem;
252}
253.detail-connected-item:last-child { border-bottom: none; }
254.detail-connected-name {
255 cursor: pointer;
256 color: #2563eb;
257}
258.detail-connected-name:hover { text-decoration: underline; }
259.detail-connected-rel {
260 color: #9ca3af;
261 font-size: 0.75rem;
262}
263.detail-activity-item {
264 padding: 0.2rem 0;
265 border-bottom: 1px solid #f3f4f6;
266 font-size: 0.82rem;
267}
268.detail-activity-item:last-child { border-bottom: none; }
269.detail-activity-day {
270 color: #6b7280;
271 font-weight: 600;
272 margin-right: 0.4rem;
273}
274.detail-entity-link {
275 display: inline-block;
276 margin-top: 0.5rem;
277 color: #2563eb;
278 text-decoration: none;
279 font-size: 0.85rem;
280}
281.detail-entity-link:hover { text-decoration: underline; }
282</style>
283
284<script src="{{ vendor_lib('vis-network') }}"></script>
285
286<script>
287(function() {
288 // --- State ---
289 let network = null;
290 let graphData = null;
291 let activeTypes = new Set(['person', 'company', 'project', 'tool']);
292 let timeDays = 90;
293 let minStrength = 0;
294 let detailOpen = false;
295
296 // --- Color maps ---
297 const TYPE_COLORS = {
298 person: '#3b82f6',
299 company: '#22c55e',
300 project: '#f59e0b',
301 tool: '#6b7280',
302 unknown: '#a1a1aa',
303 };
304
305 const EDGE_REL_COLORS = {
306 'works-on': '#8b5cf6',
307 'works-at': '#06b6d4',
308 'discusses-with': '#ec4899',
309 'collaborates-with': '#10b981',
310 'manages': '#f97316',
311 'reports-to': '#f97316',
312 'member-of': '#6366f1',
313 'uses': '#64748b',
314 };
315
316 // --- Helpers ---
317 function sinceFromDays(days) {
318 if (!days) return '';
319 const d = new Date();
320 d.setDate(d.getDate() - days);
321 return d.toISOString().slice(0,10).replace(/-/g,'');
322 }
323
324 function escapeHtml(text) {
325 const div = document.createElement('div');
326 div.textContent = text || '';
327 return div.innerHTML;
328 }
329
330 function formatDay(d) {
331 if (!d || d.length < 8) return d || '';
332 return d.slice(0,4) + '-' + d.slice(4,6) + '-' + d.slice(6,8);
333 }
334
335 // --- Data fetch ---
336 async function fetchGraph() {
337 const params = new URLSearchParams();
338 const facet = window.selectedFacet;
339 if (facet) params.set('facet', facet);
340 const since = sinceFromDays(timeDays);
341 if (since) params.set('since', since);
342 const types = Array.from(activeTypes).join(',');
343 if (types) params.set('types', types);
344 if (minStrength > 0) params.set('min_strength', minStrength);
345 params.set('limit', '100');
346
347 const resp = await fetch('/app/graph/api/graph?' + params.toString());
348 if (!resp.ok) throw new Error('Failed to fetch graph');
349 return resp.json();
350 }
351
352 async function fetchEntity(name) {
353 const params = new URLSearchParams();
354 const facet = window.selectedFacet;
355 if (facet) params.set('facet', facet);
356 const resp = await fetch('/app/graph/api/entity/' + encodeURIComponent(name) + '?' + params.toString());
357 if (!resp.ok) return null;
358 return resp.json();
359 }
360
361 // --- Graph rendering ---
362 function buildVisData(data) {
363 const maxScore = Math.max(...data.nodes.map(n => n.score), 1);
364 const scaleFactor = 30 / maxScore;
365
366 const nodes = data.nodes.map(n => {
367 const size = n.is_principal
368 ? Math.max(30, 10 + n.score * scaleFactor)
369 : Math.max(10, Math.min(40, 10 + n.score * scaleFactor));
370 const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
371 return {
372 id: n.id,
373 label: n.name,
374 size: size,
375 color: {
376 background: color,
377 border: n.is_principal ? '#f59e0b' : color,
378 highlight: { background: color, border: n.is_principal ? '#f59e0b' : '#111827' },
379 hover: { background: color, border: n.is_principal ? '#f59e0b' : '#374151' },
380 },
381 borderWidth: n.is_principal ? 3 : 1.5,
382 font: { size: Math.max(14, Math.min(22, 10 + size * 0.3)), color: '#374151' },
383 title: n.name + ' (' + n.type + ') — score: ' + n.score.toFixed(1),
384 _data: n,
385 };
386 });
387
388 const edges = data.edges.map((e, i) => {
389 if (e.edge_type === 'explicit') {
390 const relColor = EDGE_REL_COLORS[e.relationship_type] || '#9ca3af';
391 return {
392 id: 'e' + i,
393 from: e.from,
394 to: e.to,
395 width: 1 + Math.min(4, e.frequency * 0.5),
396 color: { color: relColor, opacity: 0.7, highlight: relColor, hover: relColor },
397 arrows: { to: { enabled: true, scaleFactor: 0.5 } },
398 smooth: { type: 'curvedCW', roundness: 0.15 },
399 title: (e.relationship_type || 'related') + ' (' + e.frequency + ')',
400 };
401 } else {
402 return {
403 id: 'e' + i,
404 from: e.from,
405 to: e.to,
406 width: 1 + Math.min(3, e.frequency * 0.3),
407 color: { color: '#d1d5db', opacity: 0.5, highlight: '#9ca3af', hover: '#9ca3af' },
408 dashes: [4, 4],
409 smooth: { type: 'curvedCW', roundness: 0.1 },
410 title: 'co-occurrence (' + e.frequency + ')',
411 };
412 }
413 });
414
415 return {
416 nodes: new vis.DataSet(nodes),
417 edges: new vis.DataSet(edges),
418 };
419 }
420
421 function renderGraph(data) {
422 graphData = data;
423 const container = document.getElementById('graph-canvas');
424 const loading = document.getElementById('graph-loading');
425 const empty = document.getElementById('graph-empty');
426 const controls = document.getElementById('graph-controls');
427
428 loading.style.display = 'none';
429
430 if (!data.nodes || data.nodes.length === 0) {
431 container.style.display = 'none';
432 empty.style.display = 'block';
433 controls.style.display = 'none';
434 return;
435 }
436
437 empty.style.display = 'none';
438 container.style.display = 'block';
439 controls.style.display = 'block';
440
441 const visData = buildVisData(data);
442
443 const options = {
444 physics: {
445 solver: 'forceAtlas2Based',
446 forceAtlas2Based: {
447 gravitationalConstant: -200,
448 centralGravity: 0.005,
449 springLength: 230,
450 springConstant: 0.015,
451 damping: 0.4,
452 avoidOverlap: 0.8,
453 },
454 stabilization: { iterations: 1000 },
455 },
456 nodes: {
457 shape: 'dot',
458 scaling: { min: 10, max: 40 },
459 borderWidth: 1.5,
460 shadow: { enabled: true, size: 4, x: 1, y: 1, color: 'rgba(0,0,0,0.1)' },
461 },
462 edges: {
463 smooth: { enabled: true, type: 'curvedCW', roundness: 0.15 },
464 },
465 interaction: {
466 hover: true,
467 tooltipDelay: 100,
468 hideEdgesOnDrag: true,
469 hideEdgesOnZoom: true,
470 },
471 layout: {
472 improvedLayout: true,
473 },
474 };
475
476 if (network) {
477 network.setData(visData);
478 } else {
479 network = new vis.Network(container, visData, options);
480
481 // Fit graph to container after stabilization
482 network.on('stabilizationIterationsDone', function() {
483 network.fit({ animation: { duration: 300 } });
484 });
485
486 // Click node → inspect
487 network.on('click', function(params) {
488 if (params.nodes.length > 0) {
489 const nodeId = params.nodes[0];
490 const nodeData = visData.nodes.get(nodeId);
491 if (nodeData && nodeData._data) {
492 showDetail(nodeData._data);
493 }
494 } else {
495 // Click canvas → dismiss
496 closeDetail();
497 }
498 });
499 }
500
501 // Update stats
502 updateStats(data);
503 }
504
505 function updateStats(data) {
506 const el = document.getElementById('graph-stats');
507 el.textContent = data.nodes.length + ' nodes, ' + data.edges.length + ' edges';
508 }
509
510 // --- Detail panel ---
511 function showDetail(nodeData) {
512 const panel = document.getElementById('graph-detail-panel');
513 const nameEl = document.getElementById('detail-name');
514 const bodyEl = document.getElementById('detail-body');
515
516 nameEl.textContent = nodeData.name;
517 bodyEl.innerHTML = '<div style="text-align:center;padding:2em;color:#999;">Loading...</div>';
518 panel.classList.add('open');
519 detailOpen = true;
520
521 fetchEntity(nodeData.id).then(intel => {
522 if (!intel || intel.error) {
523 bodyEl.innerHTML = '<div style="padding:1em;color:#999;">Could not load entity details.</div>';
524 return;
525 }
526 renderDetail(intel, nodeData);
527 });
528 }
529
530 function renderDetail(intel, nodeData) {
531 const bodyEl = document.getElementById('detail-body');
532 const identity = intel.identity || {};
533 const strength = intel.strength || {};
534 const typeColor = TYPE_COLORS[identity.type?.toLowerCase()] || TYPE_COLORS.unknown;
535
536 let html = '';
537
538 // Type badge
539 html += '<div>';
540 html += '<span class="detail-type-badge" style="background:' + typeColor + '">' + escapeHtml(identity.type || nodeData.type) + '</span>';
541 if (identity.is_principal) {
542 html += '<span class="detail-principal-badge">You</span>';
543 }
544 html += '</div>';
545
546 // Description
547 if (identity.description) {
548 html += '<div class="detail-description">' + escapeHtml(identity.description) + '</div>';
549 }
550
551 // Strength score
552 html += '<div class="detail-section">';
553 html += '<div class="detail-section-title">Strength Score</div>';
554 html += '<div style="font-size:1.3em;font-weight:700;color:#111827;margin-bottom:0.3rem;">' + (strength.score || 0).toFixed(1) + '</div>';
555 html += '<div class="detail-score-grid">';
556 html += scoreItem('Co-occurrence', strength.co_occurrence);
557 html += scoreItem('Appearances', strength.appearance);
558 html += scoreItem('Recency', strength.recency?.toFixed(2));
559 html += scoreItem('Facet breadth', strength.facet_breadth);
560 html += scoreItem('Observation depth', strength.observation_depth);
561 html += '</div></div>';
562
563 // Connected entities (from network field)
564 const networkEntities = intel.network || {};
565 const connectedNames = Object.keys(networkEntities).sort((a, b) => networkEntities[b] - networkEntities[a]).slice(0, 15);
566 if (connectedNames.length > 0) {
567 html += '<div class="detail-section">';
568 html += '<div class="detail-section-title">Connected Entities</div>';
569 html += '<ul class="detail-connected-list">';
570 for (const name of connectedNames) {
571 html += '<li class="detail-connected-item">';
572 html += '<span class="detail-connected-name" data-entity="' + escapeHtml(name) + '">' + escapeHtml(name) + '</span>';
573 html += '<span class="detail-connected-rel">' + networkEntities[name] + ' shared</span>';
574 html += '</li>';
575 }
576 html += '</ul></div>';
577 }
578
579 // Recent activity
580 const activity = (intel.activity || []).slice(0, 10);
581 if (activity.length > 0) {
582 html += '<div class="detail-section">';
583 html += '<div class="detail-section-title">Recent Activity</div>';
584 for (const a of activity) {
585 html += '<div class="detail-activity-item">';
586 html += '<span class="detail-activity-day">' + formatDay(a.day) + '</span>';
587 const label = a.event_title || a.signal_type || '';
588 html += escapeHtml(label);
589 if (a.target_name) html += ' → ' + escapeHtml(a.target_name);
590 html += '</div>';
591 }
592 html += '</div>';
593 }
594
595 // Link to entities app
596 html += '<a class="detail-entity-link" href="/app/entities#' + encodeURIComponent(identity.entity_id || nodeData.id) + '">View full intelligence →</a>';
597
598 bodyEl.innerHTML = html;
599
600 // Click connected entity names
601 bodyEl.querySelectorAll('.detail-connected-name').forEach(el => {
602 el.addEventListener('click', () => {
603 const eName = el.dataset.entity;
604 // Try to find this entity in the current graph
605 if (graphData) {
606 const matchNode = graphData.nodes.find(n => n.name === eName || n.id === eName);
607 if (matchNode) {
608 showDetail(matchNode);
609 if (network) network.selectNodes([matchNode.id]);
610 return;
611 }
612 }
613 // Fallback: just fetch directly
614 showDetail({ id: eName, name: eName, type: 'unknown' });
615 });
616 });
617 }
618
619 function scoreItem(label, value) {
620 return '<div class="detail-score-item"><span class="detail-score-label">' + label + '</span><span class="detail-score-value">' + (value ?? 0) + '</span></div>';
621 }
622
623 function closeDetail() {
624 document.getElementById('graph-detail-panel').classList.remove('open');
625 detailOpen = false;
626 if (network) network.unselectAll();
627 }
628
629 // --- Filter handlers ---
630 document.querySelectorAll('.graph-type-btn').forEach(btn => {
631 btn.addEventListener('click', () => {
632 const t = btn.dataset.type;
633 if (btn.classList.contains('active')) {
634 btn.classList.remove('active');
635 activeTypes.delete(t);
636 } else {
637 btn.classList.add('active');
638 activeTypes.add(t);
639 }
640 reload();
641 });
642 });
643
644 document.querySelectorAll('.graph-time-btn').forEach(btn => {
645 btn.addEventListener('click', () => {
646 document.querySelectorAll('.graph-time-btn').forEach(b => b.classList.remove('active'));
647 btn.classList.add('active');
648 timeDays = btn.dataset.days ? parseInt(btn.dataset.days) : 0;
649 reload();
650 });
651 });
652
653 const strengthSlider = document.getElementById('min-strength');
654 const strengthVal = document.getElementById('min-strength-val');
655 let strengthTimeout = null;
656 strengthSlider.addEventListener('input', () => {
657 strengthVal.textContent = strengthSlider.value;
658 });
659 strengthSlider.addEventListener('change', () => {
660 minStrength = parseInt(strengthSlider.value);
661 reload();
662 });
663
664 document.getElementById('detail-close').addEventListener('click', closeDetail);
665
666 // --- Facet awareness ---
667 window.addEventListener('facet.switch', () => {
668 reload();
669 });
670
671 // --- Load / reload ---
672 let loadCount = 0;
673 async function reload() {
674 const thisLoad = ++loadCount;
675 try {
676 const data = await fetchGraph();
677 if (thisLoad !== loadCount) return; // stale
678 renderGraph(data);
679 } catch (err) {
680 console.error('Graph load failed:', err);
681 if (thisLoad === loadCount) {
682 document.getElementById('graph-loading').textContent = 'Failed to load graph';
683 }
684 }
685 }
686
687 // Initial load
688 reload();
689})();
690</script>