personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at scratch/segment-sense-rd 690 lines 20 kB view raw
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">&times;</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>