UNPKG

@alavida/agentpack

Version:

Compiler-driven lifecycle CLI for source-backed agent skills

595 lines (529 loc) 21.6 kB
import { useEffect, useRef, useCallback } from 'react'; import * as d3 from 'd3'; const STATUS_COLORS = { current: '#8fa67e', stale: '#d4a45e', affected: '#c4956e', changed: '#d4a45e', unknown: '#9a9488', }; const SOURCE_COLOR = '#7a9abb'; const SOURCE_CHANGED_COLOR = '#c45454'; // red — urgent indicator const GLOW_COLORS = { current: { color: '#8fa67e', opacity: 0.6 }, stale: { color: '#d4a45e', opacity: 0.6 }, affected: { color: '#c4956e', opacity: 0.5 }, changed: { color: '#c45454', opacity: 0.7 }, unknown: { color: '#9a9488', opacity: 0.4 }, source: { color: '#7a9abb', opacity: 0.5 }, sourceChanged: { color: '#c45454', opacity: 0.7 }, }; function nodeRadius(node) { if (node.type === 'skill') return 14; if (node.type === 'internal-skill') return 11; return 9; } function nodeColor(node) { if (node.type === 'source') return SOURCE_COLOR; if (node.type === 'internal-skill') return '#b3c28d'; if (node.type === 'external-package') return '#8d9fc2'; return STATUS_COLORS[node.status] || STATUS_COLORS.unknown; } function isFilled(node) { if (node.type === 'source') return true; if (node.type === 'internal-skill') return false; if (node.type === 'external-package') return false; return node.status === 'current' || node.status === 'unknown'; } function diamondPath(size) { return `M0,${-size} L${size},0 L0,${size} L${-size},0 Z`; } function buildTreeHierarchy(model) { if (!model || !model.selected) return null; const sourceNodes = model.nodes.filter((n) => n.type === 'source'); const edgesBySource = new Map(); for (const edge of model.edges) { if (edge.kind !== 'requires') continue; if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []); edgesBySource.get(edge.source).push(edge.target); } const treeChildren = new Map(); const visited = new Set(); const crossLinks = []; const queue = [model.selected.id]; visited.add(model.selected.id); while (queue.length > 0) { const parentId = queue.shift(); const childIds = edgesBySource.get(parentId) || []; for (const childId of childIds) { if (visited.has(childId)) { crossLinks.push({ source: parentId, target: childId }); continue; } visited.add(childId); if (!treeChildren.has(parentId)) treeChildren.set(parentId, []); treeChildren.get(parentId).push(childId); queue.push(childId); } } const nodeMap = new Map(model.nodes.map((n) => [n.id, n])); function buildHierarchy(id) { const node = nodeMap.get(id); const children = (treeChildren.get(id) || []).map(buildHierarchy); return { data: node, children: children.length > 0 ? children : undefined }; } const rootHierarchy = buildHierarchy(model.selected.id); return { rootHierarchy, sourceNodes, crossLinks, nodeMap }; } function getThemeColors() { const s = getComputedStyle(document.documentElement); return { current: s.getPropertyValue('--status-current').trim(), stale: s.getPropertyValue('--status-stale').trim(), affected: s.getPropertyValue('--status-affected').trim(), unknown: s.getPropertyValue('--status-unknown').trim(), provenance: s.getPropertyValue('--edge-provenance').trim(), requires: s.getPropertyValue('--edge-requires').trim(), text: s.getPropertyValue('--text').trim(), textDim: s.getPropertyValue('--text-dim').trim(), }; } export function SkillGraph({ model, selectedId, onSelect, onHover, onHoverEnd, labelsVisible, knowledgeVisible, resetZoomSignal, }) { const svgRef = useRef(null); const zoomRef = useRef(null); const resetZoom = useCallback(() => { if (!svgRef.current || !zoomRef.current) return; const svg = d3.select(svgRef.current); svg.transition().duration(500).call( zoomRef.current.transform, zoomRef.current.__initialTransform || d3.zoomIdentity ); }, []); useEffect(() => { if (resetZoomSignal > 0) resetZoom(); }, [resetZoomSignal, resetZoom]); useEffect(() => { if (!model || !svgRef.current) return; const result = buildTreeHierarchy(model); if (!result) return; const { rootHierarchy, sourceNodes, crossLinks } = result; const width = window.innerWidth; const height = window.innerHeight; const theme = getThemeColors(); const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('width', width).attr('height', height); // ─── DEFS (glow filters) ─── const defs = svg.append('defs'); for (const [status, glowConfig] of Object.entries(GLOW_COLORS)) { const filter = defs.append('filter') .attr('id', `glow-${status}`) .attr('x', '-50%').attr('y', '-50%') .attr('width', '200%').attr('height', '200%'); filter.append('feGaussianBlur') .attr('stdDeviation', 5) .attr('result', 'blur'); filter.append('feFlood') .attr('flood-color', glowConfig.color) .attr('flood-opacity', glowConfig.opacity); filter.append('feComposite') .attr('in2', 'blur') .attr('operator', 'in'); const merge = filter.append('feMerge'); merge.append('feMergeNode'); merge.append('feMergeNode').attr('in', 'SourceGraphic'); } // ─── ZOOM ─── const zoom = d3.zoom() .scaleExtent([0.15, 3]) .on('zoom', (e) => g.attr('transform', e.transform)); zoomRef.current = zoom; svg.call(zoom); svg.style('cursor', 'grab'); svg.on('mousedown.cursor', () => svg.style('cursor', 'grabbing')); svg.on('mouseup.cursor', () => svg.style('cursor', 'grab')); const g = svg.append('g'); // ─── TREE LAYOUT (skills only) ─── const hierarchy = d3.hierarchy(rootHierarchy); const treeWidth = Math.max(width * 0.5, 400); const treeHeight = Math.max(hierarchy.height * 180, 280); const treeLayout = d3.tree() .size([treeWidth, treeHeight]) .separation((a, b) => { if (a.depth === 0) return 3; return a.parent === b.parent ? 1.5 : 2; }); treeLayout(hierarchy); // ─── POSITION SKILL NODES ─── const posMap = new Map(); const sourceBandY = 30; const treeTopPad = 240; // large gap between source band and tree hierarchy.descendants().forEach((d) => { posMap.set(d.data.data.id, { x: d.x, y: d.y + treeTopPad }); }); // ─── Build set of changed source IDs ─── const changedSourceIds = new Set( sourceNodes.filter((n) => n.status === 'changed').map((n) => n.id) ); // ─── FORCE-SIMULATE SOURCE POSITIONS (source-only, no link forces) ─── const provenanceEdges = model.edges.filter((e) => e.kind === 'provenance'); const hasSources = sourceNodes.length > 0; if (hasSources) { // Compute each source's ideal X as the average X of its consumers const sourceSimNodes = sourceNodes.map((src) => { const consumers = provenanceEdges .filter((e) => e.source === src.id) .map((e) => posMap.get(e.target)) .filter(Boolean); const idealX = consumers.length > 0 ? consumers.reduce((s, p) => s + p.x, 0) / consumers.length : treeWidth / 2; return { id: src.id, x: idealX, y: sourceBandY, idealX }; }); // Source-only simulation: spread apart, stay in band const sourceSim = d3.forceSimulation(sourceSimNodes) .force('collide', d3.forceCollide(70)) .force('x', d3.forceX((d) => d.idealX).strength(0.3)) .force('y', d3.forceY(sourceBandY).strength(0.8)) .force('charge', d3.forceManyBody().strength(-100)) .stop(); for (let i = 0; i < 200; i++) sourceSim.tick(); sourceSimNodes.forEach((sn) => { posMap.set(sn.id, { x: sn.x, y: sn.y }); }); } // ─── SEPARATOR LINE between source zone and skill zone ─── if (hasSources && knowledgeVisible) { const separatorY = treeTopPad - 60; g.append('line') .attr('x1', -200) .attr('y1', separatorY) .attr('x2', treeWidth + 200) .attr('y2', separatorY) .attr('stroke', theme.provenance) .attr('stroke-width', 0.5) .attr('stroke-dasharray', '3 6') .attr('opacity', 0.12) .style('pointer-events', 'none'); } // ─── PROVENANCE EDGES ─── // Changed-source edges are amber and clearly visible; others are near-invisible const provGroup = g.append('g').attr('class', 'provenance-edges'); if (knowledgeVisible) { provGroup.selectAll('path') .data(provenanceEdges) .join('path') .attr('class', 'edge provenance-edge') .attr('d', (e) => { const s = posMap.get(e.source); const t = posMap.get(e.target); if (!s || !t) return ''; const midY = s.y + (t.y - s.y) * 0.5; return `M${s.x},${s.y + 8} C${s.x},${midY} ${t.x},${midY} ${t.x},${t.y - 14}`; }) .attr('fill', 'none') .attr('stroke', (e) => changedSourceIds.has(e.source) ? SOURCE_CHANGED_COLOR : theme.provenance) .attr('stroke-dasharray', (e) => changedSourceIds.has(e.source) ? '4 3' : '2 4') .attr('opacity', (e) => { if (changedSourceIds.has(e.source)) return 0.55; // dirty provenance always visible if (e.target === model.selected.id) return 0.35; return 0.08; }) .attr('stroke-width', (e) => { if (changedSourceIds.has(e.source)) return 2; if (e.target === model.selected.id) return 1.5; return 1; }) .style('transition', 'opacity 200ms ease, stroke-width 200ms ease'); } // ─── TREE EDGES (vertical bezier) ─── const treeGroup = g.append('g').attr('class', 'tree-edges'); treeGroup.selectAll('path') .data(hierarchy.links()) .join('path') .attr('class', 'edge tree-edge') .attr('d', (d) => { const sx = d.source.x; const sy = d.source.y + treeTopPad; const tx = d.target.x; const ty = d.target.y + treeTopPad; const midY = sy + (ty - sy) * 0.5; return `M${sx},${sy} C${sx},${midY} ${tx},${midY} ${tx},${ty}`; }) .attr('fill', 'none') .attr('stroke', theme.requires) .attr('stroke-width', (d) => d.target.depth <= 1 ? 2.5 : 1.5) .attr('opacity', (d) => { const depth = d.target.depth; if (depth <= 1) return 0.5; if (depth === 2) return 0.35; return 0.2; }) .style('transition', 'opacity 200ms ease, stroke-width 200ms ease'); if (labelsVisible) { g.append('g') .attr('class', 'edge-labels') .selectAll('text') .data(hierarchy.links().filter((d) => d.target.data?.data?.context)) .join('text') .attr('x', (d) => (d.source.x + d.target.x) / 2) .attr('y', (d) => ((d.source.y + treeTopPad) + (d.target.y + treeTopPad)) / 2 - 8) .attr('fill', theme.textDim) .attr('font-size', 10) .attr('font-family', 'var(--font-mono)') .attr('text-anchor', 'middle') .attr('opacity', 0.75) .text((d) => d.target.data.data.context); } // ─── CROSS-LINKS (shared deps — dashed green) ─── const crossGroup = g.append('g').attr('class', 'cross-edges'); crossGroup.selectAll('path') .data(crossLinks) .join('path') .attr('class', 'edge cross-edge') .attr('d', (e) => { const s = posMap.get(e.source); const t = posMap.get(e.target); if (!s || !t) return ''; const midY = Math.max(s.y, t.y) + 40; return `M${s.x},${s.y} C${s.x},${midY} ${t.x},${midY} ${t.x},${t.y}`; }) .attr('fill', 'none') .attr('stroke', theme.requires) .attr('stroke-width', 1.5) .attr('stroke-dasharray', '6 4') .attr('opacity', 0.2) .style('transition', 'opacity 200ms ease'); // ─── SOURCE NODES (floating diamonds in their own band) ─── if (hasSources && knowledgeVisible) { const sourceGroup = g.append('g').attr('class', 'source-nodes'); const sourceGs = sourceGroup.selectAll('g') .data(sourceNodes) .join('g') .attr('data-node-id', (n) => n.id) .attr('data-node-type', (n) => n.type) .attr('data-node-status', (n) => n.status) .attr('transform', (n) => { const p = posMap.get(n.id); return `translate(${p.x},${p.y})`; }) .style('cursor', 'pointer') .on('click', (_, n) => onSelect(n)) .on('mouseenter', (event, n) => { highlightConnected(n, model, posMap, g); onHover(n, { x: event.clientX, y: event.clientY }); }) .on('mousemove', (event, n) => onHover(n, { x: event.clientX, y: event.clientY })) .on('mouseleave', () => { clearHighlight(g, model.selected.id, changedSourceIds); onHoverEnd(); }); // Pulsing glow ring for changed sources sourceGs.filter((n) => n.status === 'changed') .append('path') .attr('d', diamondPath(14)) .attr('fill', 'none') .attr('stroke', SOURCE_CHANGED_COLOR) .attr('stroke-width', 1.5) .style('animation', 'stale-pulse 2s ease-in-out infinite'); sourceGs.append('path') .attr('class', 'source-shape') .attr('d', diamondPath(8)) .attr('fill', (n) => { if (n.status === 'changed') return SOURCE_CHANGED_COLOR; return `${theme.provenance}55`; }) .attr('stroke', (n) => n.status === 'changed' ? SOURCE_CHANGED_COLOR : theme.provenance) .attr('stroke-width', 1.5) .attr('filter', (n) => n.status === 'changed' ? 'url(#glow-sourceChanged)' : null) .style('transition', 'filter 200ms ease'); sourceGs.append('text') .attr('class', 'node-label') .text((n) => n.path.split('/').slice(-1)[0].replace('.md', '')) .attr('x', 0) .attr('y', -16) .attr('text-anchor', 'middle') .attr('fill', (n) => n.status === 'changed' ? SOURCE_CHANGED_COLOR : theme.provenance) .attr('font-family', 'var(--font-mono)') .attr('font-size', 10) .attr('opacity', 1) .style('pointer-events', 'none') .style('display', labelsVisible ? null : 'none'); } // ─── SKILL / DEPENDENCY NODES (circles) ─── const skillNodeData = hierarchy.descendants(); const nodeGroup = g.append('g').attr('class', 'skill-nodes'); const nodeGs = nodeGroup.selectAll('g') .data(skillNodeData) .join('g') .attr('data-node-id', (d) => d.data.data.id) .attr('data-node-type', (d) => d.data.data.type) .attr('data-node-status', (d) => d.data.data.status) .attr('transform', (d) => `translate(${d.x},${d.y + treeTopPad})`) .style('cursor', 'pointer') .on('click', (_, d) => onSelect(d.data.data)) .on('mouseenter', (event, d) => { highlightConnected(d.data.data, model, posMap, g); onHover(d.data.data, { x: event.clientX, y: event.clientY }); }) .on('mousemove', (event, d) => onHover(d.data.data, { x: event.clientX, y: event.clientY })) .on('mouseleave', () => { clearHighlight(g, model.selected.id, changedSourceIds); onHoverEnd(); }); // Stale glow ring nodeGs.filter((d) => d.data.data.status === 'stale') .append('circle') .attr('r', (d) => nodeRadius(d.data.data) + 7) .attr('fill', 'none') .attr('stroke', STATUS_COLORS.stale) .attr('stroke-width', 1.5) .style('animation', 'stale-pulse 2s ease-in-out infinite'); // Main node circle nodeGs.append('circle') .attr('class', 'node-circle') .attr('r', (d) => nodeRadius(d.data.data)) .attr('fill', (d) => { const n = d.data.data; if (n.status === 'affected') return 'transparent'; return isFilled(n) ? nodeColor(n) : 'transparent'; }) .attr('stroke', (d) => { const n = d.data.data; if (n.id === selectedId) return theme.text; return nodeColor(n); }) .attr('stroke-width', (d) => { const n = d.data.data; if (n.id === selectedId) return 3; if (n.type === 'skill') return 2; if (n.status === 'affected' || !isFilled(n)) return 1.5; return 0; }) .style('transition', 'r 200ms ease, filter 200ms ease'); // Skill labels — above node nodeGs.append('text') .attr('class', 'node-label') .text((d) => d.data.data.name || d.data.data.packageName) .attr('text-anchor', 'middle') .attr('y', (d) => -nodeRadius(d.data.data) - 12) .attr('fill', (d) => d.data.data.id === model.selected.id ? theme.text : theme.textDim) .attr('font-family', 'var(--font-mono)') .attr('font-size', (d) => d.data.data.type === 'skill' ? 13 : 11) .attr('font-weight', (d) => d.data.data.type === 'skill' ? 600 : 400) .style('pointer-events', 'none') .style('display', labelsVisible ? null : 'none'); // ─── INITIAL TRANSFORM (auto-center) ─── let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const [, pos] of posMap) { if (pos.x < minX) minX = pos.x; if (pos.x > maxX) maxX = pos.x; if (pos.y < minY) minY = pos.y; if (pos.y > maxY) maxY = pos.y; } minX -= 80; maxX += 80; minY -= 50; maxY += 50; const contentWidth = maxX - minX; const contentHeight = maxY - minY; const scaleX = (width - 80) / contentWidth; const scaleY = (height - 140) / contentHeight; const scale = Math.min(scaleX, scaleY, 1) * 0.85; const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const initialTransform = d3.zoomIdentity .translate(width / 2 - centerX * scale, height * 0.42 - centerY * scale) .scale(scale); zoom.__initialTransform = initialTransform; svg.call(zoom.transform, initialTransform); }, [model, selectedId, labelsVisible, knowledgeVisible, onSelect, onHover, onHoverEnd]); return <svg data-testid="skill-graph" ref={svgRef} style={{ flex: 1, minHeight: 0 }} />; } function highlightConnected(node, model, posMap, g) { const connected = new Set([node.id]); for (const edge of model.edges) { if (edge.source === node.id) connected.add(edge.target); if (edge.target === node.id) connected.add(edge.source); } if (node.type !== 'source') { for (const edge of model.edges) { if (edge.kind === 'requires' && edge.target === node.id) { connected.add(edge.source); } } } g.selectAll('.source-nodes g').style('opacity', (n) => connected.has(n.id) ? 1 : 0.08); g.selectAll('.skill-nodes g').style('opacity', (d) => connected.has(d.data.data.id) ? 1 : 0.08); // Provenance edges: bright for connected, hidden for rest g.selectAll('.provenance-edge').style('opacity', function () { const data = d3.select(this).datum(); if (!data) return 0; return (connected.has(data.source) && connected.has(data.target)) ? 0.6 : 0; }).style('stroke-width', function () { const data = d3.select(this).datum(); if (!data) return 1; return (connected.has(data.source) && connected.has(data.target)) ? 2 : 1; }); // Tree + cross edges g.selectAll('.tree-edge').style('opacity', function () { const data = d3.select(this).datum(); if (!data?.source?.data?.data) return 0.03; const sId = data.source.data.data.id; const tId = data.target.data.data.id; return (connected.has(sId) && connected.has(tId)) ? 0.9 : 0.03; }); g.selectAll('.cross-edge').style('opacity', function () { const data = d3.select(this).datum(); if (!data) return 0.03; return (connected.has(data.source) && connected.has(data.target)) ? 0.9 : 0.03; }); const glowKey = node.type === 'source' ? (node.status === 'changed' ? 'sourceChanged' : 'source') : (node.status || 'unknown'); g.selectAll('.skill-nodes g') .filter((d) => d.data.data.id === node.id) .select('.node-circle') .attr('filter', `url(#glow-${glowKey})`); g.selectAll('.source-nodes g') .filter((n) => n.id === node.id) .select('.source-shape') .attr('filter', `url(#glow-${node.type === 'source' && node.status === 'changed' ? 'sourceChanged' : 'source'})`); } function clearHighlight(g, selectedSkillId, changedSourceIds) { g.selectAll('.source-nodes g').style('opacity', 1); g.selectAll('.skill-nodes g').style('opacity', 1); g.selectAll('.provenance-edge') .style('opacity', function () { const d = d3.select(this).datum(); if (!d) return 0.08; if (changedSourceIds && changedSourceIds.has(d.source)) return 0.55; if (d.target === selectedSkillId) return 0.35; return 0.08; }) .style('stroke-width', function () { const d = d3.select(this).datum(); if (!d) return 1; if (changedSourceIds && changedSourceIds.has(d.source)) return 2; if (d.target === selectedSkillId) return 1.5; return 1; }); g.selectAll('.tree-edge').style('opacity', null); g.selectAll('.cross-edge').style('opacity', 0.2); g.selectAll('.node-circle').attr('filter', null); // Preserve changed source glow g.selectAll('.source-shape').attr('filter', function () { const d = d3.select(this.parentNode).datum(); if (d && changedSourceIds && changedSourceIds.has(d.id)) return 'url(#glow-sourceChanged)'; return null; }); }