Spaces:
Running
Running
| <!-- FSM Force Graph: Explorable FSM - browser-style warm rendering --> | |
| <div class="fsm-force-graph"></div> | |
| <style> | |
| .fsm-force-graph { position: relative; width: 100%; min-height: 500px; } | |
| .fsm-force-graph svg { display: block; width: 100%; } | |
| .fsm-force-graph .top-bar { | |
| display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; | |
| } | |
| .fsm-force-graph .seg-control { | |
| display: inline-flex; background: var(--surface-bg); | |
| border: 1px solid var(--border-color); border-radius: 8px; | |
| padding: 3px; gap: 2px; | |
| } | |
| .fsm-force-graph .seg-control button { | |
| padding: 5px 14px; border-radius: 6px; border: none; | |
| background: transparent; font-size: 12px; font-weight: 500; | |
| color: var(--text-color); cursor: pointer; transition: all 0.15s ease; | |
| opacity: 0.6; | |
| } | |
| .fsm-force-graph .seg-control button:hover { opacity: 0.8; } | |
| .fsm-force-graph .seg-control button.active { | |
| background: var(--text-color); color: var(--page-bg); | |
| opacity: 1; font-weight: 600; | |
| } | |
| .fsm-force-graph .stat-pills { | |
| display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; | |
| } | |
| .fsm-force-graph .stat-pill { | |
| padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; | |
| border: 1px solid var(--border-color); background: var(--surface-bg); | |
| color: var(--text-color); font-variant-numeric: tabular-nums; | |
| } | |
| .fsm-force-graph .action-bar { | |
| display: flex; align-items: center; gap: 8px; margin-bottom: 10px; | |
| } | |
| .fsm-force-graph .action-bar button { | |
| padding: 4px 12px; border-radius: 6px; font-size: 11px; font-weight: 500; | |
| border: 1px solid var(--border-color); background: var(--surface-bg); | |
| color: var(--text-color); cursor: pointer; transition: all 0.15s ease; opacity: 0.6; | |
| } | |
| .fsm-force-graph .action-bar button:hover { opacity: 1; } | |
| .fsm-force-graph .tooltip { | |
| position: absolute; top: 0; left: 0; pointer-events: none; padding: 10px 14px; border-radius: 8px; | |
| font-size: 12px; line-height: 1.6; border: 1px solid var(--border-color); | |
| background: var(--surface-bg); color: var(--text-color); | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04); | |
| backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); | |
| opacity: 0; transition: opacity 0.15s ease; | |
| z-index: 100; max-width: 260px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| </style> | |
| <script> | |
| (() => { | |
| const ensureD3 = (cb) => { | |
| if (window.d3 && typeof window.d3.select === 'function') return cb(); | |
| let s = document.getElementById('d3-cdn-script'); | |
| if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); } | |
| s.addEventListener('load', () => cb(), { once: true }); | |
| }; | |
| const bootstrap = () => { | |
| const container = document.querySelector('.fsm-force-graph:not([data-mounted])'); | |
| if (!container) return; | |
| container.dataset.mounted = 'true'; | |
| const d3 = window.d3; | |
| // --- Palette (ported from browser/) --- | |
| const RUST = '#3d5a80'; | |
| const graphs = { | |
| 'ATBench': { | |
| states: ['init','user:text','assistant:tool:extract','tool:text','assistant:tool:read','assistant:tool:create','assistant:text','assistant:tool:delete','assistant:tool:update','assistant:tool:comm','assistant:tool:auth','assistant:tool:search','assistant:tool:misc','assistant:tool:compute','assistant:tool:exec'], | |
| edges: [['init','user:text'],['user:text','assistant:tool:extract'],['assistant:tool:extract','tool:text'],['tool:text','assistant:tool:read'],['assistant:tool:read','tool:text'],['tool:text','assistant:tool:create'],['assistant:tool:create','tool:text'],['tool:text','assistant:text'],['user:text','assistant:tool:read'],['tool:text','assistant:tool:delete'],['assistant:tool:delete','tool:text'],['tool:text','assistant:tool:extract'],['tool:text','assistant:tool:update'],['assistant:tool:update','tool:text'],['assistant:text','user:text'],['user:text','assistant:tool:comm'],['assistant:tool:comm','tool:text'],['user:text','assistant:tool:auth'],['assistant:tool:auth','tool:text'],['user:text','assistant:tool:search'],['assistant:tool:search','tool:text'],['user:text','assistant:tool:misc'],['assistant:tool:misc','tool:text'],['tool:text','assistant:tool:misc'],['user:text','assistant:tool:create'],['user:text','assistant:tool:delete'],['tool:text','assistant:tool:comm'],['user:text','assistant:tool:update'],['tool:text','assistant:tool:search'],['user:text','assistant:text'],['tool:text','assistant:tool:auth'],['tool:text','assistant:tool:compute'],['assistant:tool:compute','tool:text'],['user:text','assistant:tool:exec'],['assistant:tool:exec','tool:text'],['user:text','assistant:tool:compute'],['tool:text','assistant:tool:exec']] | |
| }, | |
| 'SWE-smith': { | |
| states: ['init','system:text','user:text','assistant:tool:bash','tool:text','assistant:tool:str_replace_editor','assistant:tool:submit','assistant:text','tool:tool:str','tool:tool_call'], | |
| edges: [['init','system:text'],['system:text','user:text'],['user:text','assistant:tool:bash'],['assistant:tool:bash','tool:text'],['tool:text','assistant:tool:str_replace_editor'],['assistant:tool:str_replace_editor','tool:text'],['tool:text','assistant:tool:bash'],['tool:text','assistant:tool:submit'],['assistant:tool:submit','tool:text'],['tool:text','assistant:text'],['assistant:tool:str_replace_editor','tool:tool:str'],['tool:tool:str','assistant:tool:str_replace_editor'],['tool:tool:str','assistant:tool:bash'],['tool:tool_call','assistant:tool:bash'],['assistant:tool:str_replace_editor','tool:tool_call'],['tool:tool_call','assistant:tool:str_replace_editor'],['assistant:tool:bash','tool:tool_call']] | |
| }, | |
| 'SWE-agent': { | |
| states: ['init','user:text','edit:text','execute:text','navigate:text','search:text','submit:text','user:tool:of','user:cli','assistant:text','user:tool:dict','user:tool_call','user:metadata','user:tool:132','user:tool:os','user:tool:filename','user:jupytext','user:versioneer','user:0','navigate:tool_call','search:tool_call','user:tool:node','user:tool:not','user:tool:None','user:influx2'], | |
| edges: [['init','user:text'],['user:text','edit:text'],['edit:text','user:text'],['user:text','execute:text'],['execute:text','user:text'],['user:text','navigate:text'],['navigate:text','user:text'],['user:text','search:text'],['search:text','user:text'],['user:text','submit:text'],['edit:text','user:tool:of'],['user:tool:of','execute:text'],['execute:text','user:cli'],['user:text','assistant:text'],['assistant:text','user:text'],['user:tool:dict','edit:text'],['search:text','user:tool_call'],['user:tool_call','search:text'],['execute:text','user:tool_call'],['user:tool_call','navigate:text'],['user:tool_call','edit:text'],['user:metadata','edit:text'],['navigate:text','user:tool:132'],['user:tool:132','navigate:text'],['user:tool:os','edit:text'],['edit:text','user:tool:filename'],['user:tool:filename','execute:text'],['submit:text','user:text'],['execute:text','user:jupytext'],['user:jupytext','execute:text'],['user:versioneer','edit:text'],['execute:text','user:0'],['navigate:text','user:tool_call'],['navigate:tool_call','user:text'],['search:tool_call','user:text'],['user:tool:node','execute:text'],['edit:text','user:tool:not'],['user:tool:not','execute:text'],['edit:text','user:tool:None'],['user:tool:None','execute:text'],['execute:text','user:influx2'],['user:influx2','execute:text'],['user:influx2','navigate:text']] | |
| } | |
| }; | |
| const datasetKeys = Object.keys(graphs); | |
| let currentKey = 'SWE-agent'; | |
| function shortLabel(id) { | |
| if (id === 'init') return 'init'; | |
| const parts = id.split(':'); | |
| if (parts.length >= 3) return parts[parts.length - 1].slice(0, 12); | |
| if (parts.length === 2) return parts[1].slice(0, 12); | |
| return id.slice(0, 12); | |
| } | |
| // --- UI --- | |
| const topBar = document.createElement('div'); | |
| topBar.className = 'top-bar'; | |
| const seg = document.createElement('div'); | |
| seg.className = 'seg-control'; | |
| const buttons = {}; | |
| datasetKeys.forEach(key => { | |
| const btn = document.createElement('button'); | |
| const g = graphs[key]; | |
| btn.textContent = key + ' (' + g.states.length + ')'; | |
| btn.dataset.key = key; | |
| if (key === currentKey) btn.className = 'active'; | |
| btn.addEventListener('click', () => { | |
| if (key === currentKey) return; | |
| currentKey = key; | |
| Object.values(buttons).forEach(b => b.className = ''); | |
| btn.className = 'active'; | |
| renderGraph(key); | |
| }); | |
| buttons[key] = btn; | |
| seg.appendChild(btn); | |
| }); | |
| const statPills = document.createElement('div'); | |
| statPills.className = 'stat-pills'; | |
| topBar.append(seg, statPills); | |
| container.prepend(topBar); | |
| const actionBar = document.createElement('div'); | |
| actionBar.className = 'action-bar'; | |
| const resetBtn = document.createElement('button'); | |
| resetBtn.textContent = 'Reset Layout'; | |
| actionBar.appendChild(resetBtn); | |
| container.appendChild(actionBar); | |
| const tip = document.createElement('div'); | |
| tip.className = 'tooltip'; | |
| container.appendChild(tip); | |
| let currentSimulation = null; | |
| resetBtn.addEventListener('click', () => { renderGraph(currentKey); }); | |
| function renderGraph(name) { | |
| const graph = graphs[name]; | |
| statPills.innerHTML = '<span class="stat-pill">' + graph.states.length + ' states</span><span class="stat-pill">' + graph.edges.length + ' transitions</span>'; | |
| if (currentSimulation) { currentSimulation.stop(); currentSimulation = null; } | |
| container.querySelectorAll('svg').forEach(s => s.remove()); | |
| const rect = container.getBoundingClientRect(); | |
| const W = Math.max(400, Math.round(rect.width)); | |
| const H = 460; | |
| const svg = d3.select(container).insert('svg', '.tooltip') | |
| .attr('viewBox', '-20 -10 ' + (W + 40) + ' ' + (H + 20)) | |
| .attr('width', W).attr('height', H) | |
| .style('overflow', 'hidden') | |
| .style('cursor', 'grab') | |
| .style('touch-action', 'none'); | |
| // Arrow marker - warm taupe (ported from browser/) | |
| const defs = svg.append('defs'); | |
| defs.append('marker') | |
| .attr('id', 'fg-arrow').attr('viewBox', '0 0 10 6') | |
| .attr('refX', 10).attr('refY', 3) | |
| .attr('markerWidth', 7).attr('markerHeight', 4.5) | |
| .attr('orient', 'auto') | |
| .append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', '#8a8478'); | |
| defs.append('marker') | |
| .attr('id', 'fg-arrow-rust').attr('viewBox', '0 0 10 6') | |
| .attr('refX', 10).attr('refY', 3) | |
| .attr('markerWidth', 7).attr('markerHeight', 4.5) | |
| .attr('orient', 'auto') | |
| .append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', RUST); | |
| // --- Pre-compute graph metrics --- | |
| const deg = {}; | |
| graph.states.forEach(s => { deg[s] = 0; }); | |
| graph.edges.forEach(([s, t]) => { deg[s]++; deg[t]++; }); | |
| const maxDeg = Math.max(1, ...Object.values(deg)); | |
| const edgeSet = new Set(); | |
| graph.edges.forEach(([s, t]) => edgeSet.add(s + '|' + t)); | |
| function nodeR(id) { | |
| if (id === 'init') return 13; | |
| return 10 + (deg[id] / maxDeg) * 8; | |
| } | |
| // --- D3 force simulation - run to completion, then static --- | |
| const nodes = graph.states.map(id => ({ id })); | |
| const links = graph.edges.map(([s, t]) => ({ source: s, target: t })); | |
| const simulation = d3.forceSimulation(nodes) | |
| .force('link', d3.forceLink(links).id(d => d.id).distance(55 + graph.states.length * 1.5)) | |
| .force('charge', d3.forceManyBody().strength(-180 - graph.states.length * 3)) | |
| .force('center', d3.forceCenter(W / 2, H / 2)) | |
| .force('collision', d3.forceCollide().radius(d => nodeR(d.id) + 4)) | |
| .force('x', d3.forceX(W / 2).strength(0.03)) | |
| .force('y', d3.forceY(H / 2).strength(0.03)) | |
| .stop(); | |
| // Run simulation to completion (static layout) | |
| for (let i = 0; i < 300; i++) simulation.tick(); | |
| currentSimulation = simulation; | |
| const zoomLayer = svg.append('g').attr('class', 'zoom-layer'); | |
| const linkGroup = zoomLayer.append('g'); | |
| const nodeGroup = zoomLayer.append('g'); | |
| // Pan + zoom across the whole canvas (scroll to zoom, drag background to pan). | |
| const zoom = d3.zoom().scaleExtent([0.3, 4]) | |
| .on('zoom', (ev) => zoomLayer.attr('transform', ev.transform)); | |
| svg.call(zoom); | |
| // Double-click resets the view instead of zooming in. | |
| svg.on('dblclick.zoom', () => svg.transition().duration(300).call(zoom.transform, d3.zoomIdentity)); | |
| // --- Edges --- | |
| const linkSel = linkGroup.selectAll('path').data(links).enter().append('path') | |
| .attr('fill', 'none') | |
| .attr('stroke', '#b5afa5') | |
| .attr('stroke-width', d => { | |
| // Thicker edges for high-degree endpoints | |
| const sId = typeof d.source === 'object' ? d.source.id : d.source; | |
| const tId = typeof d.target === 'object' ? d.target.id : d.target; | |
| return 0.5 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 2.5; | |
| }) | |
| .attr('stroke-opacity', d => { | |
| const sId = typeof d.source === 'object' ? d.source.id : d.source; | |
| const tId = typeof d.target === 'object' ? d.target.id : d.target; | |
| return 0.3 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 0.5; | |
| }) | |
| .attr('marker-end', 'url(#fg-arrow)'); | |
| // --- Nodes (groups with circles + labels) --- | |
| const nodeGs = nodeGroup.selectAll('g').data(nodes).enter().append('g') | |
| .attr('cursor', 'grab'); | |
| // Render node circles | |
| nodeGs.each(function(d) { | |
| const g = d3.select(this); | |
| const isInit = d.id === 'init'; | |
| const r = nodeR(d.id); | |
| const degNorm = deg[d.id] / maxDeg; | |
| if (isInit) { | |
| g.append('circle').attr('r', r) | |
| .attr('fill', 'var(--surface-bg)').attr('stroke', RUST).attr('stroke-width', 2) | |
| .attr('class', 'node-circle'); | |
| g.append('circle').attr('r', r - 3) | |
| .attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1); | |
| } else { | |
| g.append('circle').attr('r', r) | |
| .attr('fill', 'rgba(61, 90, 128, ' + (0.06 + degNorm * 0.14) + ')') | |
| .attr('stroke', degNorm > 0.5 ? RUST : '#b5afa5') | |
| .attr('stroke-width', degNorm > 0.5 ? 1.5 : 1) | |
| .attr('class', 'node-circle'); | |
| } | |
| // Label below node | |
| g.append('text') | |
| .attr('y', r + 12) | |
| .attr('text-anchor', 'middle') | |
| .attr('fill', 'var(--text-color)').attr('opacity', 0.7) | |
| .attr('font-size', '8.5px') | |
| .attr('font-family', 'IBM Plex Mono, ui-monospace, monospace') | |
| .attr('pointer-events', 'none') | |
| .text(shortLabel(d.id)); | |
| }); | |
| // --- Hover interactions --- | |
| nodeGs | |
| .on('mouseenter', function(ev, d) { | |
| const r = nodeR(d.id); | |
| d3.select(this).select('.node-circle') | |
| .transition().duration(100).attr('r', r + 3).attr('stroke', RUST).attr('stroke-width', 2.5); | |
| // Highlight connected edges | |
| linkSel.transition().duration(100) | |
| .attr('stroke', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return (sId === d.id || tId === d.id) ? RUST : '#b5afa5'; | |
| }) | |
| .attr('stroke-opacity', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return (sId === d.id || tId === d.id) ? 0.7 : 0.08; | |
| }) | |
| .attr('stroke-width', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return (sId === d.id || tId === d.id) ? 2 : 0.8; | |
| }) | |
| .attr('marker-end', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return (sId === d.id || tId === d.id) ? 'url(#fg-arrow-rust)' : 'url(#fg-arrow)'; | |
| }); | |
| // Dim other nodes | |
| nodeGs.transition().duration(100).attr('opacity', n => { | |
| if (n.id === d.id) return 1; | |
| const connected = graph.edges.some(e => (e[0] === d.id && e[1] === n.id) || (e[1] === d.id && e[0] === n.id)); | |
| return connected ? 1 : 0.2; | |
| }); | |
| // Tooltip | |
| const outEdges = graph.edges.filter(e => e[0] === d.id); | |
| const inEdges = graph.edges.filter(e => e[1] === d.id); | |
| tip.innerHTML = '<strong style="color:' + RUST + '">' + d.id + '</strong><br/>Out: ' + outEdges.length + ' In: ' + inEdges.length; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', function(ev) { | |
| const [mx, my] = d3.pointer(ev, container); | |
| const tipW = tip.offsetWidth || 120; | |
| const tipH = tip.offsetHeight || 40; | |
| const cRect = container.getBoundingClientRect(); | |
| let tx = mx + 14, ty = my - 14; | |
| if (tx + tipW > cRect.width) tx = mx - tipW - 10; | |
| if (ty + tipH > cRect.height) ty = my - tipH - 10; | |
| tip.style.transform = 'translate(' + Math.max(0, tx) + 'px,' + Math.max(0, ty) + 'px)'; | |
| }) | |
| .on('mouseleave', function(ev, d) { | |
| const r = nodeR(d.id); | |
| const degNorm = deg[d.id] / maxDeg; | |
| d3.select(this).select('.node-circle') | |
| .transition().duration(150) | |
| .attr('r', r) | |
| .attr('stroke', d.id === 'init' ? RUST : degNorm > 0.5 ? RUST : '#b5afa5') | |
| .attr('stroke-width', d.id === 'init' ? 2 : degNorm > 0.5 ? 1.5 : 1); | |
| linkSel.transition().duration(200) | |
| .attr('stroke', '#b5afa5') | |
| .attr('stroke-opacity', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return 0.3 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 0.5; | |
| }) | |
| .attr('stroke-width', l => { | |
| const sId = l.source.id, tId = l.target.id; | |
| return 0.5 + ((deg[sId] + deg[tId]) / (maxDeg * 2)) * 2.5; | |
| }) | |
| .attr('marker-end', 'url(#fg-arrow)'); | |
| nodeGs.transition().duration(200).attr('opacity', 1); | |
| tip.style.opacity = '0'; | |
| }) | |
| .call(d3.drag() | |
| .on('start', function(ev) { if (ev.sourceEvent) ev.sourceEvent.stopPropagation(); d3.select(this).attr('cursor', 'grabbing'); }) | |
| .on('drag', (ev, d) => { d.x = ev.x; d.y = ev.y; updatePositions(); }) | |
| .on('end', function() { d3.select(this).attr('cursor', 'grab'); }) | |
| ); | |
| // --- Position update helper (static, no forces) --- | |
| function updatePositions() { | |
| linkSel.attr('d', d => { | |
| const s = d.source, t = d.target; | |
| const rS = nodeR(s.id), rT = nodeR(t.id); | |
| // Self-loop (cubic bezier above node) | |
| if (s.id === t.id) { | |
| return 'M' + s.x + ',' + (s.y - rS) + | |
| ' C' + (s.x - 30) + ',' + (s.y - rS - 35) + | |
| ' ' + (s.x + 30) + ',' + (s.y - rS - 35) + | |
| ' ' + s.x + ',' + (s.y - rS); | |
| } | |
| const dx = t.x - s.x, dy = t.y - s.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = dx / dist, ny = dy / dist; | |
| // Bidirectional (quadratic bezier with perpendicular offset) | |
| if (edgeSet.has(t.id + '|' + s.id)) { | |
| const curve = 22; | |
| const mx = (s.x + t.x) / 2 - ny * curve; | |
| const my = (s.y + t.y) / 2 + nx * curve; | |
| const x1 = s.x + nx * (rS + 2) - ny * 3; | |
| const y1 = s.y + ny * (rS + 2) + nx * 3; | |
| const x2 = t.x - nx * (rT + 4) - ny * 3; | |
| const y2 = t.y - ny * (rT + 4) + nx * 3; | |
| return 'M' + x1 + ',' + y1 + ' Q' + mx + ',' + my + ' ' + x2 + ',' + y2; | |
| } | |
| // Unidirectional (straight line) | |
| const x1 = s.x + nx * (rS + 2); | |
| const y1 = s.y + ny * (rS + 2); | |
| const x2 = t.x - nx * (rT + 4); | |
| const y2 = t.y - ny * (rT + 4); | |
| return 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2; | |
| }); | |
| nodeGs.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')'); | |
| } | |
| // Initial render from pre-computed positions | |
| updatePositions(); | |
| } | |
| renderGraph(currentKey); | |
| }; | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| else ensureD3(bootstrap); | |
| })(); | |
| </script> | |