a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

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

extract HTML templates from main.py into ui.py

main.py: 851 → 414 lines. three inline HTML pages (home, status,
memory graph) plus shared CSS/favicon constants moved to ui.py.
main.py now just calls home_page(), status_page(), memory_page().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+471 -450
+13 -450
src/bot/main.py
··· 21 21 from bot.memory import NamespaceMemory 22 22 from bot.services.notification_poller import NotificationPoller 23 23 from bot.status import bot_status 24 + from bot.ui import home_page, memory_page, status_page 24 25 25 26 logger = logging.getLogger("bot.main") 26 27 ··· 102 103 103 104 PHI_DID = "did:plc:65sucjiel52gefhcdcypynsr" 104 105 105 - VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1">' 106 - 107 - # per-page favicons — thematically distinct inline SVGs 108 - _FAVICON_HOME = ( 109 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 110 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 111 - "%3Cpath d=%22M2 16 L8 16 L11 6 L16 26 L21 10 L24 16 L30 16%22" 112 - " fill=%22none%22 stroke=%2258a6ff%22 stroke-width=%222.5%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/%3E" 113 - '%3C/svg%3E">' 114 - ) 115 - _FAVICON_STATUS = ( 116 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 117 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 118 - "%3Ccircle cx=%2216%22 cy=%2216%22 r=%2212%22 fill=%22none%22 stroke=%222ea043%22 stroke-width=%222%22/%3E" 119 - "%3Cline x1=%2216%22 y1=%2216%22 x2=%2216%22 y2=%228%22 stroke=%222ea043%22 stroke-width=%222.5%22 stroke-linecap=%22round%22/%3E" 120 - "%3Cline x1=%2216%22 y1=%2216%22 x2=%2222%22 y2=%2218%22 stroke=%222ea043%22 stroke-width=%222%22 stroke-linecap=%22round%22/%3E" 121 - "%3Ccircle cx=%2216%22 cy=%2216%22 r=%222%22 fill=%222ea043%22/%3E" 122 - '%3C/svg%3E">' 123 - ) 124 - _FAVICON_MEMORY = ( 125 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 126 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 127 - "%3Cline x1=%228%22 y1=%2210%22 x2=%2220%22 y2=%227%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 128 - "%3Cline x1=%228%22 y1=%2210%22 x2=%2214%22 y2=%2224%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 129 - "%3Cline x1=%2220%22 y1=%227%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 130 - "%3Cline x1=%2214%22 y1=%2224%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 131 - "%3Ccircle cx=%228%22 cy=%2210%22 r=%223.5%22 fill=%22a371f7%22/%3E" 132 - "%3Ccircle cx=%2220%22 cy=%227%22 r=%223%22 fill=%2258a6ff%22/%3E" 133 - "%3Ccircle cx=%2226%22 cy=%2220%22 r=%222.5%22 fill=%222ea043%22/%3E" 134 - "%3Ccircle cx=%2214%22 cy=%2224%22 r=%223%22 fill=%228b949e%22/%3E" 135 - '%3C/svg%3E">' 136 - ) 137 - 138 - NAV_HTML = """<nav> 139 - <a href="/" class="nav-brand">phi</a> 140 - <div class="nav-links"> 141 - <a href="/status">status</a> 142 - <a href="/memory">memory</a> 143 - <a href="/docs">api</a> 144 - </div> 145 - </nav>""" 146 - 147 - BASE_STYLE = """ 148 - * { margin: 0; padding: 0; box-sizing: border-box; } 149 - body { 150 - font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 151 - background: #0d1117; color: #c9d1d9; font-size: 14px; 152 - -webkit-font-smoothing: antialiased; 153 - } 154 - nav { 155 - padding: 14px 20px; 156 - border-bottom: 1px solid #30363d; 157 - background: #0d1117; 158 - display: flex; align-items: center; justify-content: space-between; 159 - } 160 - .nav-brand { 161 - color: #c9d1d9; text-decoration: none; 162 - font-size: 15px; font-weight: 500; letter-spacing: 0.5px; 163 - } 164 - .nav-links { display: flex; gap: 6px; } 165 - .nav-links a { 166 - color: #8b949e; text-decoration: none; 167 - font-size: 13px; letter-spacing: 0.3px; 168 - padding: 6px 12px; border-radius: 16px; 169 - transition: background 0.15s, color 0.15s; 170 - } 171 - .nav-links a:hover { color: #c9d1d9; background: #161b22; } 172 - .container { max-width: 640px; margin: 0 auto; padding: 32px 20px; } 173 - a { color: #58a6ff; text-decoration: none; } 174 - a:hover { text-decoration: underline; } 175 - """ 176 - 177 106 178 107 @app.get("/", response_class=HTMLResponse) 179 108 async def root(): 180 109 """Landing page with activity feed.""" 181 110 status = "online" if bot_status.polling_active else "offline" 182 111 status_color = "#2ea043" if bot_status.polling_active else "#da3633" 183 - return f"""<!DOCTYPE html> 184 - <html><head><title>phi</title>{VIEWPORT_META}{_FAVICON_HOME}<style>{BASE_STYLE} 185 - .header {{ margin-bottom: 28px; }} 186 - h1 {{ font-size: 28px; font-weight: 400; margin-bottom: 6px; }} 187 - .subtitle {{ color: #8b949e; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }} 188 - .status-dot {{ 189 - display: inline-block; width: 8px; height: 8px; 190 - border-radius: 50%; flex-shrink: 0; 191 - }} 192 - .desc {{ color: #8b949e; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }} 193 - .stats {{ 194 - display: flex; gap: 24px; margin-bottom: 32px; 195 - font-size: 13px; color: #8b949e; flex-wrap: wrap; 196 - }} 197 - .stat-val {{ color: #c9d1d9; font-size: 18px; display: block; margin-bottom: 2px; }} 198 - .feed-title {{ font-size: 15px; color: #8b949e; margin-bottom: 16px; font-weight: 400; }} 199 - .feed {{ display: flex; flex-direction: column; gap: 10px; }} 200 - .card {{ 201 - background: #161b22; border-radius: 8px; padding: 14px 16px; 202 - border-left: 3px solid #30363d; 203 - }} 204 - .card-post {{ border-left-color: #58a6ff; }} 205 - .card-note {{ border-left-color: #a371f7; }} 206 - .card-url {{ border-left-color: #2ea043; }} 207 - .card-header {{ 208 - display: flex; align-items: center; gap: 6px; 209 - margin-bottom: 6px; 210 - }} 211 - .card-icon {{ flex-shrink: 0; }} 212 - .card-icon svg {{ display: block; }} 213 - .card-type {{ 214 - font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; 215 - font-weight: 500; 216 - }} 217 - .type-post {{ color: #58a6ff; }} 218 - .type-note {{ color: #a371f7; }} 219 - .type-url {{ color: #2ea043; }} 220 - .card-title {{ font-size: 14px; font-weight: 500; color: #c9d1d9; margin-bottom: 4px; }} 221 - .card-text {{ font-size: 14px; line-height: 1.5; margin-bottom: 8px; word-break: break-word; }} 222 - .card-text a {{ color: #58a6ff; text-decoration: none; }} 223 - .card-text a:hover {{ text-decoration: underline; }} 224 - .card-domain {{ 225 - font-size: 12px; color: #8b949e; margin-bottom: 6px; 226 - display: flex; align-items: center; gap: 4px; 227 - }} 228 - .card-domain a {{ color: #8b949e; }} 229 - .card-domain a:hover {{ color: #c9d1d9; }} 230 - .card-meta {{ font-size: 12px; color: #484f58; }} 231 - .card-meta a {{ color: #484f58; }} 232 - .card-meta a:hover {{ color: #8b949e; }} 233 - #feed-loading {{ color: #484f58; font-size: 13px; }} 234 - </style></head> 235 - <body> 236 - {NAV_HTML} 237 - <div class="container"> 238 - <div class="header"> 239 - <h1>phi</h1> 240 - <div class="subtitle"> 241 - <span class="status-dot" style="background:{status_color}"></span> 242 - <span>{status}</span> 243 - <span>&middot;</span> 244 - <a href="https://bsky.app/profile/{settings.bluesky_handle}">@{settings.bluesky_handle}</a> 245 - </div> 246 - </div> 247 - <p class="desc"> 248 - bluesky bot with episodic memory and mcp tools. 249 - learns from conversations, remembers across sessions. 250 - </p> 251 - <div class="stats"> 252 - <div><span class="stat-val">{bot_status.uptime_str}</span>uptime</div> 253 - <div><span class="stat-val">{bot_status.mentions_received}</span>mentions</div> 254 - <div><span class="stat-val">{bot_status.responses_sent}</span>responses</div> 255 - </div> 256 - <h2 class="feed-title">recent activity</h2> 257 - <div class="feed" id="feed"> 258 - <div id="feed-loading">loading...</div> 259 - </div> 260 - </div> 261 - <script> 262 - function timeAgo(iso) {{ 263 - const s = (Date.now() - new Date(iso).getTime()) / 1000; 264 - if (s < 60) return Math.floor(s) + 's ago'; 265 - if (s < 3600) return Math.floor(s / 60) + 'm ago'; 266 - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 267 - return Math.floor(s / 86400) + 'd ago'; 268 - }} 269 - function truncate(s, n) {{ return s.length > n ? s.slice(0, n) + '...' : s; }} 270 - function linkify(text) {{ 271 - return text.replace(/(https?:\/\/[^\s<>"{{}}|\\^`\[\]]+)/g, 272 - '<a href="$1" target="_blank" rel="noopener">$1</a>'); 273 - }} 274 - function getDomain(url) {{ 275 - try {{ return new URL(url).hostname.replace(/^www\./, ''); }} 276 - catch {{ return ''; }} 277 - }} 278 - const icons = {{ 279 - post: `<svg width="14" height="14" viewBox="0 0 600 530" fill="#58a6ff"> 280 - <path d="M135.72 44.03C202.22 93.87 284.5 149.63 300 163.14c15.5-13.51 97.78-69.27 164.28-119.11C528.23-2.96 600-21.03 600 66.94c0 17.58-10.06 147.67-15.96 168.71-20.48 73.22-95.26 91.94-163.03 80.59 118.4 20.18 148.52 86.98 83.52 153.79C395 580.88 300 538.04 300 538.04s-95-42.84-204.53 67.97C30.47 418.22 60.59 351.42 178.99 331.24c-67.77 11.35-142.55-7.37-163.03-80.59C10.06 229.61 0 99.52 0 81.94c0-87.97 71.77-69.9 135.72-37.91z"/> 281 - </svg>`, 282 - note: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#a371f7" stroke-width="1.5"> 283 - <path d="M8 1l2.12 4.3 4.74.69-3.43 3.34.81 4.72L8 11.77l-4.24 2.23.81-4.72L1.14 5.94l4.74-.69L8 1z"/> 284 - </svg>`, 285 - url: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#2ea043" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 286 - <path d="M6.75 9.25a3.5 3.5 0 005-.5M9.25 6.75a3.5 3.5 0 00-5 .5M10 3.5l1-1a2.12 2.12 0 013 3l-1 1M6 12.5l-1 1a2.12 2.12 0 01-3-3l1-1"/> 287 - </svg>` 288 - }}; 289 - const labels = {{ 290 - post: 'bluesky', 291 - note: 'semble note', 292 - url: 'semble bookmark' 293 - }}; 294 - function viewUrl(item) {{ 295 - if (item.url) return item.url; 296 - if (item.uri && item.uri.startsWith('at://')) return 'https://pds.ls/' + item.uri; 297 - return ''; 298 - }} 299 - fetch('/api/activity') 300 - .then(r => r.json()) 301 - .then(items => {{ 302 - const el = document.getElementById('feed'); 303 - document.getElementById('feed-loading').remove(); 304 - if (!items.length) {{ el.textContent = 'no recent activity'; return; }} 305 - el.innerHTML = items.map(i => {{ 306 - const domain = i.url ? getDomain(i.url) : ''; 307 - const domainHtml = (i.type === 'url' && domain) 308 - ? `<div class="card-domain"><a href="${{i.url}}" target="_blank" rel="noopener">${{domain}}</a></div>` 309 - : ''; 310 - const titleHtml = i.title ? `<div class="card-title">${{i.title}}</div>` : ''; 311 - const link = viewUrl(i); 312 - return ` 313 - <div class="card card-${{i.type}}"> 314 - <div class="card-header"> 315 - <span class="card-icon">${{icons[i.type] || ''}}</span> 316 - <div class="card-type type-${{i.type}}">${{labels[i.type] || i.type}}</div> 317 - </div> 318 - ${{domainHtml}} 319 - ${{titleHtml}} 320 - <div class="card-text">${{linkify(truncate(i.text || '', 300))}}</div> 321 - <div class="card-meta"> 322 - ${{timeAgo(i.time)}} 323 - ${{link ? ` &middot; <a href="${{link}}" target="_blank" rel="noopener">view</a>` : ''}} 324 - </div> 325 - </div>`; 326 - }}).join(''); 327 - }}) 328 - .catch(() => {{ 329 - document.getElementById('feed-loading').textContent = 'failed to load activity'; 330 - }}); 331 - </script> 332 - </body></html>""" 112 + return home_page( 113 + handle=settings.bluesky_handle, 114 + status=status, 115 + status_color=status_color, 116 + uptime=bot_status.uptime_str, 117 + mentions=bot_status.mentions_received, 118 + responses=bot_status.responses_sent, 119 + ) 333 120 334 121 335 122 @app.get("/health") ··· 441 228 442 229 443 230 @app.get("/status", response_class=HTMLResponse) 444 - async def status_page(): 231 + async def status_page_route(): 445 232 """Status page.""" 446 233 447 234 def format_time_ago(timestamp): ··· 476 263 for label, value, color in metrics 477 264 ) 478 265 479 - return f"""<!DOCTYPE html> 480 - <html><head><title>phi &middot; status</title>{VIEWPORT_META}{_FAVICON_STATUS}<style>{BASE_STYLE} 481 - h1 {{ font-size: 22px; font-weight: 400; margin-bottom: 24px; }} 482 - .grid {{ 483 - display: grid; grid-template-columns: 1fr 1fr; gap: 10px; 484 - }} 485 - .metric-card {{ 486 - background: #161b22; border-radius: 8px; padding: 16px; 487 - border: 1px solid #21262d; 488 - }} 489 - .metric-label {{ font-size: 12px; color: #484f58; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }} 490 - .metric-value {{ font-size: 18px; font-weight: 400; }} 491 - </style></head> 492 - <body> 493 - {NAV_HTML} 494 - <div class="container"> 495 - <h1>status</h1> 496 - <div class="grid">{cards_html}</div> 497 - </div> 498 - </body></html>""" 266 + return status_page(cards_html=cards_html) 499 267 500 268 501 269 _TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" ··· 641 409 642 410 643 411 @app.get("/memory", response_class=HTMLResponse) 644 - async def memory_page(): 412 + async def memory_page_route(): 645 413 """Interactive memory graph visualization.""" 646 - return f"""<!DOCTYPE html> 647 - <html><head><title>phi &middot; memory</title>{VIEWPORT_META}{_FAVICON_MEMORY} 648 - <script src="https://d3js.org/d3.v7.min.js"></script> 649 - <style>{BASE_STYLE} 650 - body {{ overflow: hidden; }} 651 - nav {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; }} 652 - #graph {{ width: 100vw; height: 100vh; }} 653 - .tooltip {{ 654 - position: absolute; padding: 8px 12px; 655 - background: #161b22; border: 1px solid #30363d; 656 - border-radius: 6px; font-size: 13px; 657 - pointer-events: none; opacity: 0; 658 - color: #c9d1d9; max-width: 280px; 659 - }} 660 - .legend {{ 661 - position: fixed; bottom: 16px; left: 16px; 662 - background: #161b22; border: 1px solid #30363d; 663 - border-radius: 8px; padding: 14px 16px; font-size: 12px; 664 - max-width: 220px; 665 - }} 666 - .legend-title {{ color: #8b949e; font-size: 11px; margin-bottom: 8px; line-height: 1.4; }} 667 - .legend-item {{ display: flex; align-items: center; margin: 5px 0; }} 668 - .legend-dot {{ 669 - width: 8px; height: 8px; border-radius: 50%; 670 - margin-right: 10px; flex-shrink: 0; 671 - }} 672 - .legend-label {{ color: #c9d1d9; }} 673 - #loading {{ 674 - position: fixed; top: 50%; left: 50%; 675 - transform: translate(-50%, -50%); 676 - color: #8b949e; font-size: 14px; 677 - }} 678 - </style></head> 679 - <body> 680 - {NAV_HTML} 681 - <div id="loading">loading...</div> 682 - <div id="graph"></div> 683 - <div class="tooltip" id="tooltip"></div> 684 - <div class="legend"> 685 - <div class="legend-title">social graph &middot; positioned by semantic similarity</div> 686 - <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div><span class="legend-label">phi (self)</span></div> 687 - <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div><span class="legend-label">identities phi knows</span></div> 688 - </div> 689 - <script> 690 - const colors = {{ phi: '#58a6ff', user: '#2ea043' }}; 691 - const radii = {{ phi: 14, user: 9 }}; 692 - 693 - async function fetchAvatars(nodes) {{ 694 - const identities = nodes 695 - .filter(d => d.type === 'phi' || d.type === 'user') 696 - .map(d => {{ 697 - const h = d.label.replace(/^@/, ''); 698 - return d.type === 'phi' ? '{settings.bluesky_handle}' : h; 699 - }}) 700 - .filter(h => h && !h.includes('example')); 701 - if (!identities.length) return {{}}; 702 - const map = {{}}; 703 - // batch into chunks of 25 (getProfiles limit) 704 - for (let i = 0; i < identities.length; i += 25) {{ 705 - const chunk = identities.slice(i, i + 25); 706 - const params = chunk.map(h => 'actors=' + encodeURIComponent(h)).join('&'); 707 - try {{ 708 - const res = await fetch('https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?' + params); 709 - if (!res.ok) continue; 710 - const data = await res.json(); 711 - for (const p of data.profiles || []) {{ 712 - if (p.avatar) map[p.handle] = p.avatar; 713 - }} 714 - }} catch {{ /* skip failed batch */ }} 715 - }} 716 - return map; 717 - }} 718 - 719 - fetch('/api/memory/graph') 720 - .then(r => r.json()) 721 - .then(async data => {{ 722 - document.getElementById('loading').remove(); 723 - if (!data.nodes.length) return; 724 - 725 - const avatarMap = await fetchAvatars(data.nodes); 726 - // attach avatar URLs to nodes 727 - data.nodes.forEach(d => {{ 728 - if (d.type === 'phi') d.avatar = avatarMap['{settings.bluesky_handle}']; 729 - else if (d.type === 'user') d.avatar = avatarMap[d.label.replace(/^@/, '')]; 730 - }}); 731 - 732 - const width = window.innerWidth; 733 - const height = window.innerHeight; 734 - const pad = 60; 735 - const tooltip = d3.select('#tooltip'); 736 - 737 - const sx = d => d.x != null ? pad + (d.x + 1) / 2 * (width - 2 * pad) : width / 2; 738 - const sy = d => d.y != null ? pad + (d.y + 1) / 2 * (height - 2 * pad) : height / 2; 739 - 740 - data.nodes.forEach(d => {{ 741 - d.sx = sx(d); 742 - d.sy = sy(d); 743 - d.x = d.sx; 744 - d.y = d.sy; 745 - }}); 746 - 747 - const svg = d3.select('#graph') 748 - .append('svg') 749 - .attr('width', width) 750 - .attr('height', height); 751 - 752 - const defs = svg.append('defs'); 753 - const g = svg.append('g'); 754 - let currentZoom = d3.zoomIdentity; 755 - 756 - // create avatar patterns for nodes that have them 757 - data.nodes.filter(d => d.avatar).forEach((d, i) => {{ 758 - const r = radii[d.type]; 759 - const pid = 'avatar-' + i; 760 - d._patternId = pid; 761 - defs.append('pattern') 762 - .attr('id', pid) 763 - .attr('width', 1).attr('height', 1) 764 - .attr('patternContentUnits', 'objectBoundingBox') 765 - .append('image') 766 - .attr('href', d.avatar) 767 - .attr('width', 1).attr('height', 1) 768 - .attr('preserveAspectRatio', 'xMidYMid slice'); 769 - }}); 770 - 771 - svg.call(d3.zoom() 772 - .scaleExtent([0.2, 5]) 773 - .on('zoom', e => {{ 774 - g.attr('transform', e.transform); 775 - currentZoom = e.transform; 776 - label.attr('font-size', d => {{ 777 - const base = d.type === 'phi' ? 13 : 10; 778 - return base / Math.max(currentZoom.k, 0.5); 779 - }}); 780 - }})); 781 - 782 - const edgeOpacity = () => 0.7; 783 - 784 - const simulation = d3.forceSimulation(data.nodes) 785 - .force('link', d3.forceLink(data.edges).id(d => d.id).distance(40)) 786 - .force('charge', d3.forceManyBody().strength(-80)) 787 - .force('x', d3.forceX(d => d.sx).strength(0.3)) 788 - .force('y', d3.forceY(d => d.sy).strength(0.3)) 789 - .force('collision', d3.forceCollide().radius(d => radii[d.type] + 4)); 790 - 791 - const link = g.append('g') 792 - .selectAll('line') 793 - .data(data.edges) 794 - .join('line') 795 - .attr('stroke', '#21262d') 796 - .attr('stroke-width', 1) 797 - .attr('stroke-opacity', 0.5); 798 - 799 - const node = g.append('g') 800 - .selectAll('circle') 801 - .data(data.nodes) 802 - .join('circle') 803 - .attr('r', d => radii[d.type]) 804 - .attr('fill', d => d._patternId ? `url(#${{d._patternId}})` : colors[d.type]) 805 - .attr('stroke', d => d._patternId ? colors[d.type] : '#0d1117') 806 - .attr('stroke-width', d => d._patternId ? 2 : 1.5) 807 - .style('cursor', 'grab') 808 - .call(d3.drag() 809 - .on('start', (e, d) => {{ 810 - if (!e.active) simulation.alphaTarget(0.3).restart(); 811 - d.fx = d.x; d.fy = d.y; 812 - }}) 813 - .on('drag', (e, d) => {{ d.fx = e.x; d.fy = e.y; }}) 814 - .on('end', (e, d) => {{ 815 - if (!e.active) simulation.alphaTarget(0); 816 - d.fx = null; d.fy = null; 817 - }})) 818 - .on('mouseover', (e, d) => {{ 819 - tooltip.style('opacity', 1) 820 - .html('<strong>' + d.label + '</strong><br><span style="color:' + colors[d.type] + '">' + d.type + '</span>'); 821 - }}) 822 - .on('mousemove', e => {{ 823 - tooltip.style('left', (e.pageX + 12) + 'px') 824 - .style('top', (e.pageY - 12) + 'px'); 825 - }}) 826 - .on('mouseout', () => tooltip.style('opacity', 0)); 827 - 828 - const label = g.append('g') 829 - .selectAll('text') 830 - .data(data.nodes) 831 - .join('text') 832 - .text(d => d.label) 833 - .attr('font-size', d => d.type === 'phi' ? 13 : 10) 834 - .attr('font-family', "'SF Mono', 'Cascadia Code', 'Fira Code', monospace") 835 - .attr('fill', '#8b949e') 836 - .attr('text-anchor', 'middle') 837 - .attr('dy', d => radii[d.type] + 14); 838 - 839 - simulation.on('tick', () => {{ 840 - link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) 841 - .attr('x2', d => d.target.x).attr('y2', d => d.target.y) 842 - .attr('stroke-opacity', d => edgeOpacity(d.source, d.target)); 843 - node.attr('cx', d => d.x).attr('cy', d => d.y); 844 - label.attr('x', d => d.x).attr('y', d => d.y); 845 - }}); 846 - }}) 847 - .catch(err => {{ 848 - document.getElementById('loading').textContent = 'failed to load: ' + err; 849 - }}); 850 - </script> 851 - </body></html>""" 414 + return memory_page(handle=settings.bluesky_handle)
+458
src/bot/ui.py
··· 1 + """HTML page templates for phi's web UI.""" 2 + 3 + VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1">' 4 + 5 + _FAVICON_HOME = ( 6 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 7 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 8 + "%3Cpath d=%22M2 16 L8 16 L11 6 L16 26 L21 10 L24 16 L30 16%22" 9 + " fill=%22none%22 stroke=%2258a6ff%22 stroke-width=%222.5%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/%3E" 10 + '%3C/svg%3E">' 11 + ) 12 + _FAVICON_STATUS = ( 13 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 14 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 15 + "%3Ccircle cx=%2216%22 cy=%2216%22 r=%2212%22 fill=%22none%22 stroke=%222ea043%22 stroke-width=%222%22/%3E" 16 + "%3Cline x1=%2216%22 y1=%2216%22 x2=%2216%22 y2=%228%22 stroke=%222ea043%22 stroke-width=%222.5%22 stroke-linecap=%22round%22/%3E" 17 + "%3Cline x1=%2216%22 y1=%2216%22 x2=%2222%22 y2=%2218%22 stroke=%222ea043%22 stroke-width=%222%22 stroke-linecap=%22round%22/%3E" 18 + "%3Ccircle cx=%2216%22 cy=%2216%22 r=%222%22 fill=%222ea043%22/%3E" 19 + '%3C/svg%3E">' 20 + ) 21 + _FAVICON_MEMORY = ( 22 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 23 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 24 + "%3Cline x1=%228%22 y1=%2210%22 x2=%2220%22 y2=%227%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 25 + "%3Cline x1=%228%22 y1=%2210%22 x2=%2214%22 y2=%2224%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 26 + "%3Cline x1=%2220%22 y1=%227%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 27 + "%3Cline x1=%2214%22 y1=%2224%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 28 + "%3Ccircle cx=%228%22 cy=%2210%22 r=%223.5%22 fill=%22a371f7%22/%3E" 29 + "%3Ccircle cx=%2220%22 cy=%227%22 r=%223%22 fill=%2258a6ff%22/%3E" 30 + "%3Ccircle cx=%2226%22 cy=%2220%22 r=%222.5%22 fill=%222ea043%22/%3E" 31 + "%3Ccircle cx=%2214%22 cy=%2224%22 r=%223%22 fill=%228b949e%22/%3E" 32 + '%3C/svg%3E">' 33 + ) 34 + 35 + NAV_HTML = """<nav> 36 + <a href="/" class="nav-brand">phi</a> 37 + <div class="nav-links"> 38 + <a href="/status">status</a> 39 + <a href="/memory">memory</a> 40 + <a href="/docs">api</a> 41 + </div> 42 + </nav>""" 43 + 44 + BASE_STYLE = """ 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + body { 47 + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 48 + background: #0d1117; color: #c9d1d9; font-size: 14px; 49 + -webkit-font-smoothing: antialiased; 50 + } 51 + nav { 52 + padding: 14px 20px; 53 + border-bottom: 1px solid #30363d; 54 + background: #0d1117; 55 + display: flex; align-items: center; justify-content: space-between; 56 + } 57 + .nav-brand { 58 + color: #c9d1d9; text-decoration: none; 59 + font-size: 15px; font-weight: 500; letter-spacing: 0.5px; 60 + } 61 + .nav-links { display: flex; gap: 6px; } 62 + .nav-links a { 63 + color: #8b949e; text-decoration: none; 64 + font-size: 13px; letter-spacing: 0.3px; 65 + padding: 6px 12px; border-radius: 16px; 66 + transition: background 0.15s, color 0.15s; 67 + } 68 + .nav-links a:hover { color: #c9d1d9; background: #161b22; } 69 + .container { max-width: 640px; margin: 0 auto; padding: 32px 20px; } 70 + a { color: #58a6ff; text-decoration: none; } 71 + a:hover { text-decoration: underline; } 72 + """ 73 + 74 + 75 + def home_page( 76 + *, 77 + handle: str, 78 + status: str, 79 + status_color: str, 80 + uptime: str, 81 + mentions: int, 82 + responses: int, 83 + ) -> str: 84 + return f"""<!DOCTYPE html> 85 + <html><head><title>phi</title>{VIEWPORT_META}{_FAVICON_HOME}<style>{BASE_STYLE} 86 + .header {{ margin-bottom: 28px; }} 87 + h1 {{ font-size: 28px; font-weight: 400; margin-bottom: 6px; }} 88 + .subtitle {{ color: #8b949e; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }} 89 + .status-dot {{ 90 + display: inline-block; width: 8px; height: 8px; 91 + border-radius: 50%; flex-shrink: 0; 92 + }} 93 + .desc {{ color: #8b949e; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }} 94 + .stats {{ 95 + display: flex; gap: 24px; margin-bottom: 32px; 96 + font-size: 13px; color: #8b949e; flex-wrap: wrap; 97 + }} 98 + .stat-val {{ color: #c9d1d9; font-size: 18px; display: block; margin-bottom: 2px; }} 99 + .feed-title {{ font-size: 15px; color: #8b949e; margin-bottom: 16px; font-weight: 400; }} 100 + .feed {{ display: flex; flex-direction: column; gap: 10px; }} 101 + .card {{ 102 + background: #161b22; border-radius: 8px; padding: 14px 16px; 103 + border-left: 3px solid #30363d; 104 + }} 105 + .card-post {{ border-left-color: #58a6ff; }} 106 + .card-note {{ border-left-color: #a371f7; }} 107 + .card-url {{ border-left-color: #2ea043; }} 108 + .card-header {{ 109 + display: flex; align-items: center; gap: 6px; 110 + margin-bottom: 6px; 111 + }} 112 + .card-icon {{ flex-shrink: 0; }} 113 + .card-icon svg {{ display: block; }} 114 + .card-type {{ 115 + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; 116 + font-weight: 500; 117 + }} 118 + .type-post {{ color: #58a6ff; }} 119 + .type-note {{ color: #a371f7; }} 120 + .type-url {{ color: #2ea043; }} 121 + .card-title {{ font-size: 14px; font-weight: 500; color: #c9d1d9; margin-bottom: 4px; }} 122 + .card-text {{ font-size: 14px; line-height: 1.5; margin-bottom: 8px; word-break: break-word; }} 123 + .card-text a {{ color: #58a6ff; text-decoration: none; }} 124 + .card-text a:hover {{ text-decoration: underline; }} 125 + .card-domain {{ 126 + font-size: 12px; color: #8b949e; margin-bottom: 6px; 127 + display: flex; align-items: center; gap: 4px; 128 + }} 129 + .card-domain a {{ color: #8b949e; }} 130 + .card-domain a:hover {{ color: #c9d1d9; }} 131 + .card-meta {{ font-size: 12px; color: #484f58; }} 132 + .card-meta a {{ color: #484f58; }} 133 + .card-meta a:hover {{ color: #8b949e; }} 134 + #feed-loading {{ color: #484f58; font-size: 13px; }} 135 + </style></head> 136 + <body> 137 + {NAV_HTML} 138 + <div class="container"> 139 + <div class="header"> 140 + <h1>phi</h1> 141 + <div class="subtitle"> 142 + <span class="status-dot" style="background:{status_color}"></span> 143 + <span>{status}</span> 144 + <span>&middot;</span> 145 + <a href="https://bsky.app/profile/{handle}">@{handle}</a> 146 + </div> 147 + </div> 148 + <p class="desc"> 149 + bluesky bot with episodic memory and mcp tools. 150 + learns from conversations, remembers across sessions. 151 + </p> 152 + <div class="stats"> 153 + <div><span class="stat-val">{uptime}</span>uptime</div> 154 + <div><span class="stat-val">{mentions}</span>mentions</div> 155 + <div><span class="stat-val">{responses}</span>responses</div> 156 + </div> 157 + <h2 class="feed-title">recent activity</h2> 158 + <div class="feed" id="feed"> 159 + <div id="feed-loading">loading...</div> 160 + </div> 161 + </div> 162 + <script> 163 + function timeAgo(iso) {{ 164 + const s = (Date.now() - new Date(iso).getTime()) / 1000; 165 + if (s < 60) return Math.floor(s) + 's ago'; 166 + if (s < 3600) return Math.floor(s / 60) + 'm ago'; 167 + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 168 + return Math.floor(s / 86400) + 'd ago'; 169 + }} 170 + function truncate(s, n) {{ return s.length > n ? s.slice(0, n) + '...' : s; }} 171 + function linkify(text) {{ 172 + return text.replace(/(https?:\\/\\/[^\\s<>"{{}}|\\\\^`\\[\\]]+)/g, 173 + '<a href="$1" target="_blank" rel="noopener">$1</a>'); 174 + }} 175 + function getDomain(url) {{ 176 + try {{ return new URL(url).hostname.replace(/^www\\./, ''); }} 177 + catch {{ return ''; }} 178 + }} 179 + const icons = {{ 180 + post: `<svg width="14" height="14" viewBox="0 0 600 530" fill="#58a6ff"> 181 + <path d="M135.72 44.03C202.22 93.87 284.5 149.63 300 163.14c15.5-13.51 97.78-69.27 164.28-119.11C528.23-2.96 600-21.03 600 66.94c0 17.58-10.06 147.67-15.96 168.71-20.48 73.22-95.26 91.94-163.03 80.59 118.4 20.18 148.52 86.98 83.52 153.79C395 580.88 300 538.04 300 538.04s-95-42.84-204.53 67.97C30.47 418.22 60.59 351.42 178.99 331.24c-67.77 11.35-142.55-7.37-163.03-80.59C10.06 229.61 0 99.52 0 81.94c0-87.97 71.77-69.9 135.72-37.91z"/> 182 + </svg>`, 183 + note: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#a371f7" stroke-width="1.5"> 184 + <path d="M8 1l2.12 4.3 4.74.69-3.43 3.34.81 4.72L8 11.77l-4.24 2.23.81-4.72L1.14 5.94l4.74-.69L8 1z"/> 185 + </svg>`, 186 + url: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#2ea043" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 187 + <path d="M6.75 9.25a3.5 3.5 0 005-.5M9.25 6.75a3.5 3.5 0 00-5 .5M10 3.5l1-1a2.12 2.12 0 013 3l-1 1M6 12.5l-1 1a2.12 2.12 0 01-3-3l1-1"/> 188 + </svg>` 189 + }}; 190 + const labels = {{ 191 + post: 'bluesky', 192 + note: 'semble note', 193 + url: 'semble bookmark' 194 + }}; 195 + function viewUrl(item) {{ 196 + if (item.url) return item.url; 197 + if (item.uri && item.uri.startsWith('at://')) return 'https://pds.ls/' + item.uri; 198 + return ''; 199 + }} 200 + fetch('/api/activity') 201 + .then(r => r.json()) 202 + .then(items => {{ 203 + const el = document.getElementById('feed'); 204 + document.getElementById('feed-loading').remove(); 205 + if (!items.length) {{ el.textContent = 'no recent activity'; return; }} 206 + el.innerHTML = items.map(i => {{ 207 + const domain = i.url ? getDomain(i.url) : ''; 208 + const domainHtml = (i.type === 'url' && domain) 209 + ? `<div class="card-domain"><a href="${{i.url}}" target="_blank" rel="noopener">${{domain}}</a></div>` 210 + : ''; 211 + const titleHtml = i.title ? `<div class="card-title">${{i.title}}</div>` : ''; 212 + const link = viewUrl(i); 213 + return ` 214 + <div class="card card-${{i.type}}"> 215 + <div class="card-header"> 216 + <span class="card-icon">${{icons[i.type] || ''}}</span> 217 + <div class="card-type type-${{i.type}}">${{labels[i.type] || i.type}}</div> 218 + </div> 219 + ${{domainHtml}} 220 + ${{titleHtml}} 221 + <div class="card-text">${{linkify(truncate(i.text || '', 300))}}</div> 222 + <div class="card-meta"> 223 + ${{timeAgo(i.time)}} 224 + ${{link ? ` &middot; <a href="${{link}}" target="_blank" rel="noopener">view</a>` : ''}} 225 + </div> 226 + </div>`; 227 + }}).join(''); 228 + }}) 229 + .catch(() => {{ 230 + document.getElementById('feed-loading').textContent = 'failed to load activity'; 231 + }}); 232 + </script> 233 + </body></html>""" 234 + 235 + 236 + def status_page(*, cards_html: str) -> str: 237 + return f"""<!DOCTYPE html> 238 + <html><head><title>phi &middot; status</title>{VIEWPORT_META}{_FAVICON_STATUS}<style>{BASE_STYLE} 239 + h1 {{ font-size: 22px; font-weight: 400; margin-bottom: 24px; }} 240 + .grid {{ 241 + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; 242 + }} 243 + .metric-card {{ 244 + background: #161b22; border-radius: 8px; padding: 16px; 245 + border: 1px solid #21262d; 246 + }} 247 + .metric-label {{ font-size: 12px; color: #484f58; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }} 248 + .metric-value {{ font-size: 18px; font-weight: 400; }} 249 + </style></head> 250 + <body> 251 + {NAV_HTML} 252 + <div class="container"> 253 + <h1>status</h1> 254 + <div class="grid">{cards_html}</div> 255 + </div> 256 + </body></html>""" 257 + 258 + 259 + def memory_page(*, handle: str) -> str: 260 + return f"""<!DOCTYPE html> 261 + <html><head><title>phi &middot; memory</title>{VIEWPORT_META}{_FAVICON_MEMORY} 262 + <script src="https://d3js.org/d3.v7.min.js"></script> 263 + <style>{BASE_STYLE} 264 + body {{ overflow: hidden; }} 265 + nav {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; }} 266 + #graph {{ width: 100vw; height: 100vh; }} 267 + .tooltip {{ 268 + position: absolute; padding: 8px 12px; 269 + background: #161b22; border: 1px solid #30363d; 270 + border-radius: 6px; font-size: 13px; 271 + pointer-events: none; opacity: 0; 272 + color: #c9d1d9; max-width: 280px; 273 + }} 274 + .legend {{ 275 + position: fixed; bottom: 16px; left: 16px; 276 + background: #161b22; border: 1px solid #30363d; 277 + border-radius: 8px; padding: 14px 16px; font-size: 12px; 278 + max-width: 220px; 279 + }} 280 + .legend-title {{ color: #8b949e; font-size: 11px; margin-bottom: 8px; line-height: 1.4; }} 281 + .legend-item {{ display: flex; align-items: center; margin: 5px 0; }} 282 + .legend-dot {{ 283 + width: 8px; height: 8px; border-radius: 50%; 284 + margin-right: 10px; flex-shrink: 0; 285 + }} 286 + .legend-label {{ color: #c9d1d9; }} 287 + #loading {{ 288 + position: fixed; top: 50%; left: 50%; 289 + transform: translate(-50%, -50%); 290 + color: #8b949e; font-size: 14px; 291 + }} 292 + </style></head> 293 + <body> 294 + {NAV_HTML} 295 + <div id="loading">loading...</div> 296 + <div id="graph"></div> 297 + <div class="tooltip" id="tooltip"></div> 298 + <div class="legend"> 299 + <div class="legend-title">social graph &middot; positioned by semantic similarity</div> 300 + <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div><span class="legend-label">phi (self)</span></div> 301 + <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div><span class="legend-label">identities phi knows</span></div> 302 + </div> 303 + <script> 304 + const colors = {{ phi: '#58a6ff', user: '#2ea043' }}; 305 + const radii = {{ phi: 14, user: 9 }}; 306 + 307 + async function fetchAvatars(nodes) {{ 308 + const identities = nodes 309 + .filter(d => d.type === 'phi' || d.type === 'user') 310 + .map(d => {{ 311 + const h = d.label.replace(/^@/, ''); 312 + return d.type === 'phi' ? '{handle}' : h; 313 + }}) 314 + .filter(h => h && !h.includes('example')); 315 + if (!identities.length) return {{}}; 316 + const map = {{}}; 317 + for (let i = 0; i < identities.length; i += 25) {{ 318 + const chunk = identities.slice(i, i + 25); 319 + const params = chunk.map(h => 'actors=' + encodeURIComponent(h)).join('&'); 320 + try {{ 321 + const res = await fetch('https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?' + params); 322 + if (!res.ok) continue; 323 + const data = await res.json(); 324 + for (const p of data.profiles || []) {{ 325 + if (p.avatar) map[p.handle] = p.avatar; 326 + }} 327 + }} catch {{ /* skip failed batch */ }} 328 + }} 329 + return map; 330 + }} 331 + 332 + fetch('/api/memory/graph') 333 + .then(r => r.json()) 334 + .then(async data => {{ 335 + document.getElementById('loading').remove(); 336 + if (!data.nodes.length) return; 337 + 338 + const avatarMap = await fetchAvatars(data.nodes); 339 + data.nodes.forEach(d => {{ 340 + if (d.type === 'phi') d.avatar = avatarMap['{handle}']; 341 + else if (d.type === 'user') d.avatar = avatarMap[d.label.replace(/^@/, '')]; 342 + }}); 343 + 344 + const width = window.innerWidth; 345 + const height = window.innerHeight; 346 + const pad = 60; 347 + const tooltip = d3.select('#tooltip'); 348 + 349 + const sx = d => d.x != null ? pad + (d.x + 1) / 2 * (width - 2 * pad) : width / 2; 350 + const sy = d => d.y != null ? pad + (d.y + 1) / 2 * (height - 2 * pad) : height / 2; 351 + 352 + data.nodes.forEach(d => {{ 353 + d.sx = sx(d); 354 + d.sy = sy(d); 355 + d.x = d.sx; 356 + d.y = d.sy; 357 + }}); 358 + 359 + const svg = d3.select('#graph') 360 + .append('svg') 361 + .attr('width', width) 362 + .attr('height', height); 363 + 364 + const defs = svg.append('defs'); 365 + const g = svg.append('g'); 366 + let currentZoom = d3.zoomIdentity; 367 + 368 + data.nodes.filter(d => d.avatar).forEach((d, i) => {{ 369 + const pid = 'avatar-' + i; 370 + d._patternId = pid; 371 + defs.append('pattern') 372 + .attr('id', pid) 373 + .attr('width', 1).attr('height', 1) 374 + .attr('patternContentUnits', 'objectBoundingBox') 375 + .append('image') 376 + .attr('href', d.avatar) 377 + .attr('width', 1).attr('height', 1) 378 + .attr('preserveAspectRatio', 'xMidYMid slice'); 379 + }}); 380 + 381 + svg.call(d3.zoom() 382 + .scaleExtent([0.2, 5]) 383 + .on('zoom', e => {{ 384 + g.attr('transform', e.transform); 385 + currentZoom = e.transform; 386 + label.attr('font-size', d => {{ 387 + const base = d.type === 'phi' ? 13 : 10; 388 + return base / Math.max(currentZoom.k, 0.5); 389 + }}); 390 + }})); 391 + 392 + const simulation = d3.forceSimulation(data.nodes) 393 + .force('link', d3.forceLink(data.edges).id(d => d.id).distance(40)) 394 + .force('charge', d3.forceManyBody().strength(-80)) 395 + .force('x', d3.forceX(d => d.sx).strength(0.3)) 396 + .force('y', d3.forceY(d => d.sy).strength(0.3)) 397 + .force('collision', d3.forceCollide().radius(d => radii[d.type] + 4)); 398 + 399 + const link = g.append('g') 400 + .selectAll('line') 401 + .data(data.edges) 402 + .join('line') 403 + .attr('stroke', '#21262d') 404 + .attr('stroke-width', 1) 405 + .attr('stroke-opacity', 0.5); 406 + 407 + const node = g.append('g') 408 + .selectAll('circle') 409 + .data(data.nodes) 410 + .join('circle') 411 + .attr('r', d => radii[d.type]) 412 + .attr('fill', d => d._patternId ? `url(#${{d._patternId}})` : colors[d.type]) 413 + .attr('stroke', d => d._patternId ? colors[d.type] : '#0d1117') 414 + .attr('stroke-width', d => d._patternId ? 2 : 1.5) 415 + .style('cursor', 'grab') 416 + .call(d3.drag() 417 + .on('start', (e, d) => {{ 418 + if (!e.active) simulation.alphaTarget(0.3).restart(); 419 + d.fx = d.x; d.fy = d.y; 420 + }}) 421 + .on('drag', (e, d) => {{ d.fx = e.x; d.fy = e.y; }}) 422 + .on('end', (e, d) => {{ 423 + if (!e.active) simulation.alphaTarget(0); 424 + d.fx = null; d.fy = null; 425 + }})) 426 + .on('mouseover', (e, d) => {{ 427 + tooltip.style('opacity', 1) 428 + .html('<strong>' + d.label + '</strong><br><span style="color:' + colors[d.type] + '">' + d.type + '</span>'); 429 + }}) 430 + .on('mousemove', e => {{ 431 + tooltip.style('left', (e.pageX + 12) + 'px') 432 + .style('top', (e.pageY - 12) + 'px'); 433 + }}) 434 + .on('mouseout', () => tooltip.style('opacity', 0)); 435 + 436 + const label = g.append('g') 437 + .selectAll('text') 438 + .data(data.nodes) 439 + .join('text') 440 + .text(d => d.label) 441 + .attr('font-size', d => d.type === 'phi' ? 13 : 10) 442 + .attr('font-family', "'SF Mono', 'Cascadia Code', 'Fira Code', monospace") 443 + .attr('fill', '#8b949e') 444 + .attr('text-anchor', 'middle') 445 + .attr('dy', d => radii[d.type] + 14); 446 + 447 + simulation.on('tick', () => {{ 448 + link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) 449 + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); 450 + node.attr('cx', d => d.x).attr('cy', d => d.y); 451 + label.attr('x', d => d.x).attr('y', d => d.y); 452 + }}); 453 + }}) 454 + .catch(err => {{ 455 + document.getElementById('loading').textContent = 'failed to load: ' + err; 456 + }}); 457 + </script> 458 + </body></html>"""