From 9f73501e3339a15faa44e8fa77ca047fe9dec94d Mon Sep 17 00:00:00 2001 From: Dhwanil Mori <136145445+Dhwanil25@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:10:25 -0400 Subject: [PATCH 1/2] feat: hexagonal nodes, bezier edges, thought bubbles, token/cost tracking - FlowGraph: hexagonal agent nodes, quadratic bezier curved edges, live thought bubbles on active agents, token progress bars under nodes - Engine: per-agent tokensIn/tokensOut/costUsd via Anthropic SSE usage events, TOKEN_PRICES table, totalCostUsd on MAState - UniversePage: header metrics strip showing total tokens + estimated cost - TimelinePanel: added to repo Co-Authored-By: Claude Sonnet 4.6 --- src/components/FlowGraph.tsx | 574 ++++++++++++++++++++++++++ src/components/TimelinePanel.tsx | 124 ++++++ src/components/pages/UniversePage.tsx | 67 ++- src/lib/multiAgentEngine.ts | 100 ++++- 4 files changed, 847 insertions(+), 18 deletions(-) create mode 100644 src/components/FlowGraph.tsx create mode 100644 src/components/TimelinePanel.tsx diff --git a/src/components/FlowGraph.tsx b/src/components/FlowGraph.tsx new file mode 100644 index 0000000..015ab53 --- /dev/null +++ b/src/components/FlowGraph.tsx @@ -0,0 +1,574 @@ +import { useEffect, useRef, useCallback } from 'react' +import { type MAState, type MAToolCall, ROLE_COLORS } from '@/lib/multiAgentEngine' + +interface Props { + state: MAState + selectedId: string | null + onSelectAgent: (id: string | null) => void +} + +interface FNode { + id: string + x: number + y: number + r: number + color: string + label: string + sub: string + status: string + type: 'agent' | 'tool' +} + +interface FEdge { + fromId: string + toId: string + color: string + active: boolean + dashed: boolean +} + +interface FParticle { + fromId: string + toId: string + t: number + speed: number + color: string +} + +const TOOL_COLORS: Record = { + web_search: '#3b82f6', + llm_call: '#8b5cf6', + browser_navigate: '#22d3ee', + browser_snapshot: '#06b6d4', + browser_read: '#0891b2', + browser_click: '#0e7490', + browser_fill: '#155e75', +} + +function toolColor(tool: string) { + return TOOL_COLORS[tool] ?? '#64748b' +} + +function toolShortName(tool: string) { + if (tool === 'web_search') return 'search' + if (tool === 'llm_call') return 'llm' + return tool.replace('browser_', '') +} + +// Draw a regular hexagon centered at (cx, cy) with "radius" r +function hexPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + r * Math.cos(angle) + const y = cy + r * Math.sin(angle) + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.closePath() +} + +// Quadratic bezier midpoint (curved edge between two nodes) +function bezierCurve(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, curvature = 0.25) { + const mx = (x1 + x2) / 2 + const my = (y1 + y2) / 2 + const dx = x2 - x1, dy = y2 - y1 + const len = Math.sqrt(dx * dx + dy * dy) || 1 + // Control point offset perpendicular to the line + const cx = mx - (dy / len) * len * curvature + const cy = my + (dx / len) * len * curvature + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.quadraticCurveTo(cx, cy, x2, y2) + return { cx, cy } // return control point for particle positioning +} + +// Get point along a quadratic bezier at t +function bezierPoint(x1: number, y1: number, cpx: number, cpy: number, x2: number, y2: number, t: number) { + const mt = 1 - t + return { + x: mt * mt * x1 + 2 * mt * t * cpx + t * t * x2, + y: mt * mt * y1 + 2 * mt * t * cpy + t * t * y2, + } +} + +// World-space layout: orchestrator at (0,0), workers on a circle, tool calls orbiting workers +function computeLayout(state: MAState): { nodes: Map; edges: FEdge[] } { + const nodes = new Map() + const edges: FEdge[] = [] + + const agents = state.agents + const toolCalls = state.toolCalls ?? [] + const workers = agents.filter(a => a.id !== 'orchestrator') + const orch = agents.find(a => a.id === 'orchestrator') + + if (orch) { + nodes.set(orch.id, { + id: orch.id, x: 0, y: 0, r: 30, + color: ROLE_COLORS[orch.role] ?? '#f97316', + label: orch.name, sub: orch.modelLabel ?? '', + status: orch.status, type: 'agent', + }) + } + + const R1 = Math.max(200, workers.length * 48) + workers.forEach((w, i) => { + const angle = (i / workers.length) * Math.PI * 2 - Math.PI / 2 + const x = R1 * Math.cos(angle) + const y = R1 * Math.sin(angle) + nodes.set(w.id, { + id: w.id, x, y, r: 22, + color: ROLE_COLORS[w.role] ?? '#6366f1', + label: w.name, sub: w.modelLabel ?? '', + status: w.status, type: 'agent', + }) + if (orch) { + const active = w.status === 'working' || w.status === 'thinking' + edges.push({ fromId: orch.id, toId: w.id, color: ROLE_COLORS[w.role] ?? '#6366f1', active, dashed: false }) + } + }) + + // Group tool calls by agent + const tcByAgent = new Map() + for (const tc of toolCalls) { + const arr = tcByAgent.get(tc.agentId) ?? [] + arr.push(tc) + tcByAgent.set(tc.agentId, arr) + } + + for (const [agentId, tcs] of tcByAgent) { + const parent = nodes.get(agentId) + if (!parent) continue + const baseAngle = Math.atan2(parent.y, parent.x) + const R2 = 80 + const spread = Math.min(Math.PI * 0.75, tcs.length * 0.35) + tcs.forEach((tc, i) => { + const offset = tcs.length > 1 ? (i / (tcs.length - 1) - 0.5) * spread : 0 + const angle = baseAngle + offset + const x = parent.x + R2 * Math.cos(angle) + const y = parent.y + R2 * Math.sin(angle) + const color = toolColor(tc.tool) + nodes.set(tc.id, { + id: tc.id, x, y, r: 11, + color, label: toolShortName(tc.tool), + sub: tc.label.slice(0, 22), + status: tc.status, type: 'tool', + }) + edges.push({ fromId: agentId, toId: tc.id, color, active: tc.status === 'running', dashed: true }) + }) + } + + return { nodes, edges } +} + +// Bezier control point cache so particles can follow the same curve +const cpCache = new Map() + +export function FlowGraph({ state, selectedId, onSelectAgent }: Props) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const stateRef = useRef(state) + stateRef.current = state + const selRef = useRef(selectedId) + selRef.current = selectedId + + const nodesRef = useRef>(new Map()) + const edgesRef = useRef([]) + const particlesRef = useRef([]) + const seenRef = useRef>(new Set()) + const rafRef = useRef(0) + const camRef = useRef({ x: 0, y: 0, scale: 1 }) + const dragRef = useRef({ active: false, moved: false, sx: 0, sy: 0, cx: 0, cy: 0 }) + const sizeRef = useRef({ w: 800, h: 600 }) + + // Recompute layout when agents or tool calls change + useEffect(() => { + const { nodes, edges } = computeLayout(stateRef.current) + nodesRef.current = nodes + edgesRef.current = edges + cpCache.clear() + }, [state.agents, state.toolCalls]) // eslint-disable-line react-hooks/exhaustive-deps + + // Animation loop + useEffect(() => { + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) return + + const resize = () => { + const { clientWidth: w, clientHeight: h } = container + const dpr = window.devicePixelRatio + canvas.width = w * dpr + canvas.height = h * dpr + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + sizeRef.current = { w, h } + camRef.current.x = w / 2 + camRef.current.y = h / 2 + const { nodes, edges } = computeLayout(stateRef.current) + nodesRef.current = nodes + edgesRef.current = edges + cpCache.clear() + } + resize() + const ro = new ResizeObserver(resize) + ro.observe(container) + + let t = 0 + const draw = () => { + rafRef.current = requestAnimationFrame(draw) + t += 0.016 + + const ctx = canvas.getContext('2d') + if (!ctx) return + const dpr = window.devicePixelRatio + const { w, h } = sizeRef.current + const { x: camX, y: camY, scale } = camRef.current + const s = stateRef.current + + ctx.clearRect(0, 0, w * dpr, h * dpr) + ctx.save() + ctx.scale(dpr, dpr) + + // Subtle radial background + const bg = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.6) + bg.addColorStop(0, 'rgba(99,102,241,0.04)') + bg.addColorStop(1, 'rgba(8,8,24,0)') + ctx.fillStyle = bg + ctx.fillRect(0, 0, w, h) + + ctx.translate(camX, camY) + ctx.scale(scale, scale) + + const nodes = nodesRef.current + const edges = edgesRef.current + + // ── Spawn particles for new messages ───────────────────────────────── + for (const msg of (s.messages ?? [])) { + if (seenRef.current.has(msg.id)) continue + seenRef.current.add(msg.id) + const from = nodes.get(msg.fromId) + const to = nodes.get(msg.toId) + if (from && to) { + for (let k = 0; k < 3; k++) { + particlesRef.current.push({ + fromId: msg.fromId, toId: msg.toId, + t: k * 0.15, speed: 0.008 + Math.random() * 0.006, color: from.color, + }) + } + } + } + // Spawn particles for running tool calls + for (const tc of (s.toolCalls ?? [])) { + const key = `tc_run_${tc.id}` + if (tc.status === 'running' && !seenRef.current.has(key)) { + seenRef.current.add(key) + for (let k = 0; k < 2; k++) { + particlesRef.current.push({ + fromId: tc.agentId, toId: tc.id, + t: k * 0.3, speed: 0.014 + Math.random() * 0.008, color: toolColor(tc.tool), + }) + } + } + } + + // ── Draw curved bezier edges ────────────────────────────────────────── + for (const edge of edges) { + const from = nodes.get(edge.fromId) + const to = nodes.get(edge.toId) + if (!from || !to) continue + + const cacheKey = `${edge.fromId}→${edge.toId}` + ctx.setLineDash(edge.dashed ? [4, 4] : []) + ctx.strokeStyle = edge.color + (edge.active ? 'aa' : '2a') + ctx.lineWidth = edge.active ? 1.5 : 0.8 + + const result = bezierCurve(ctx, from.x, from.y, to.x, to.y, edge.dashed ? 0.18 : 0.22) + cpCache.set(cacheKey, result) + ctx.stroke() + ctx.setLineDash([]) + } + + // ── Draw + advance particles along bezier curves ────────────────────── + particlesRef.current = particlesRef.current.filter(p => { + p.t += p.speed + if (p.t >= 1) return false + const from = nodes.get(p.fromId) + const to = nodes.get(p.toId) + if (!from || !to) return false + + const cacheKey = `${p.fromId}→${p.toId}` + const cp = cpCache.get(cacheKey) ?? { cx: (from.x + to.x) / 2, cy: (from.y + to.y) / 2 } + const { x: px, y: py } = bezierPoint(from.x, from.y, cp.cx, cp.cy, to.x, to.y, p.t) + + // Glow halo + const glow = ctx.createRadialGradient(px, py, 0, px, py, 8) + glow.addColorStop(0, p.color + '55') + glow.addColorStop(1, p.color + '00') + ctx.beginPath(); ctx.arc(px, py, 8, 0, Math.PI * 2) + ctx.fillStyle = glow; ctx.fill() + // Core dot + ctx.beginPath(); ctx.arc(px, py, 2.5, 0, Math.PI * 2) + ctx.fillStyle = p.color + 'dd'; ctx.fill() + return true + }) + + // ── Draw nodes ──────────────────────────────────────────────────────── + for (const node of nodes.values()) { + const isSel = selRef.current === node.id + const isActive = node.status === 'working' || node.status === 'thinking' + const isDone = node.status === 'done' + const isError = node.status === 'error' + const pulse = isActive ? 1 + Math.sin(t * 3.5 + node.id.length * 0.7) * 0.08 : 1 + const r = node.r * pulse + + // Glow for active/selected + if (isActive || isSel) { + const glowR = r * 2.8 + const glow = ctx.createRadialGradient(node.x, node.y, r * 0.4, node.x, node.y, glowR) + glow.addColorStop(0, node.color + '44') + glow.addColorStop(1, node.color + '00') + ctx.beginPath(); ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2) + ctx.fillStyle = glow; ctx.fill() + } + + if (node.type === 'tool') { + // Diamond for tool nodes + ctx.save() + ctx.translate(node.x, node.y) + ctx.rotate(Math.PI / 4) + const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.3, 0, 0, 0, r * 1.6) + grad.addColorStop(0, node.color + 'ee') + grad.addColorStop(1, node.color + '55') + ctx.beginPath() + ctx.rect(-r, -r, r * 2, r * 2) + ctx.fillStyle = grad + ctx.fill() + if (node.status === 'running') { + ctx.strokeStyle = node.color + 'cc' + ctx.lineWidth = 1.5 + ctx.stroke() + } + ctx.restore() + } else { + // ── Hexagonal agent nodes ───────────────────────────────────────── + const grad = ctx.createRadialGradient(node.x - r * 0.3, node.y - r * 0.3, 0, node.x, node.y, r * 1.4) + grad.addColorStop(0, node.color + 'ee') + grad.addColorStop(1, node.color + '44') + + hexPath(ctx, node.x, node.y, r) + ctx.fillStyle = grad + ctx.fill() + + if (isSel) { + hexPath(ctx, node.x, node.y, r) + ctx.strokeStyle = node.color + 'ff' + ctx.lineWidth = 2.5 + ctx.stroke() + } + + // Spinning status ring for active agents + if (isActive) { + ctx.beginPath() + ctx.arc(node.x, node.y, r + 6, t * 1.2, t * 1.2 + Math.PI * 1.5) + ctx.strokeStyle = node.color + '88' + ctx.lineWidth = 1.5; ctx.stroke() + } + + // Done / error badge + if (isDone) { + ctx.beginPath(); ctx.arc(node.x + r * 0.65, node.y - r * 0.65, 6, 0, Math.PI * 2) + ctx.fillStyle = '#10b981'; ctx.fill() + } + if (isError) { + ctx.beginPath(); ctx.arc(node.x + r * 0.65, node.y - r * 0.65, 6, 0, Math.PI * 2) + ctx.fillStyle = '#ef4444'; ctx.fill() + } + + // ── Token progress bar ────────────────────────────────────────── + const agentData = s.agents.find(a => a.id === node.id) + if (agentData?.tokensOut) { + const maxTokens = 8096 + const fill = Math.min(agentData.tokensOut / maxTokens, 1) + const bw = r * 1.6, bh = 3 + const bx = node.x - bw / 2, by = node.y + r + 4 + ctx.fillStyle = 'rgba(255,255,255,0.08)' + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 1.5); ctx.fill() + ctx.fillStyle = node.color + 'aa' + ctx.beginPath(); ctx.roundRect(bx, by, bw * fill, bh, 1.5); ctx.fill() + } + } + + // Label below node + ctx.textAlign = 'center' + const agentData2 = node.type === 'agent' ? s.agents.find(a => a.id === node.id) : null + const hasTokenBar = agentData2?.tokensOut ? 1 : 0 + const ly = node.y + node.r + 14 + hasTokenBar * 6 + const fontSize = node.type === 'agent' ? 11 : 9 + ctx.font = `${node.type === 'agent' ? 700 : 600} ${fontSize}px Inter, ui-sans-serif, sans-serif` + ctx.fillStyle = node.color + 'ee' + ctx.fillText(node.label, node.x, ly) + if (node.sub) { + ctx.font = `9px Inter, ui-sans-serif, sans-serif` + ctx.fillStyle = 'rgba(255,255,255,0.3)' + ctx.fillText(node.sub, node.x, ly + 12) + } + if (node.type === 'agent' && node.status && node.status !== 'idle') { + ctx.font = '8px Inter, ui-sans-serif, sans-serif' + const sc = node.status === 'done' ? '#10b981' + : node.status === 'error' ? '#ef4444' + : isActive ? node.color + : 'rgba(255,255,255,0.25)' + ctx.fillStyle = sc + ctx.fillText(node.status.toUpperCase(), node.x, ly + 23) + } + + // ── Thought bubble for active agent nodes ───────────────────────── + if (node.type === 'agent' && isActive && agentData2?.output) { + const thought = agentData2.output.replace(/^↻[^\n]+\n\n/, '').trim().slice(-80) + if (thought.length > 8) { + const bx = node.x + r + 8 + const by = node.y - 22 + const bw = 120, bh = 34 + const alpha = 0.7 + Math.sin(t * 2) * 0.05 + + // Glassmorphism bubble + ctx.save() + ctx.globalAlpha = alpha + ctx.fillStyle = 'rgba(15,15,35,0.82)' + ctx.strokeStyle = node.color + '44' + ctx.lineWidth = 1 + ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 6); ctx.fill(); ctx.stroke() + + // Connector dot + ctx.beginPath(); ctx.arc(bx - 4, by + bh / 2, 3, 0, Math.PI * 2) + ctx.fillStyle = node.color + '66'; ctx.fill() + + // Text + ctx.fillStyle = 'rgba(255,255,255,0.75)' + ctx.font = '7.5px Inter, ui-sans-serif, sans-serif' + ctx.textAlign = 'left' + // Word-wrap into 2 lines + const words = thought.split(' ') + let line = '' + let lineY = by + 12 + for (const word of words) { + const test = line ? line + ' ' + word : word + if (ctx.measureText(test).width > bw - 8 && line) { + ctx.fillText(line, bx + 4, lineY) + line = word; lineY += 10 + if (lineY > by + bh - 4) break + } else { line = test } + } + if (line && lineY <= by + bh - 4) ctx.fillText(line, bx + 4, lineY) + + ctx.restore() + ctx.textAlign = 'center' + } + } + } + + // ── Idle hint ───────────────────────────────────────────────────────── + if (s.phase === 'idle') { + ctx.textAlign = 'center' + const iconR = 32 + const glowI = ctx.createRadialGradient(0, 0, 0, 0, 0, iconR * 2) + glowI.addColorStop(0, 'rgba(99,102,241,0.2)') + glowI.addColorStop(1, 'rgba(99,102,241,0)') + ctx.beginPath(); ctx.arc(0, 0, iconR * 2, 0, Math.PI * 2) + ctx.fillStyle = glowI; ctx.fill() + ctx.beginPath(); ctx.arc(0, 0, iconR, 0, Math.PI * 2) + ctx.strokeStyle = 'rgba(99,102,241,0.3)'; ctx.lineWidth = 1; ctx.stroke() + ctx.font = '26px sans-serif'; ctx.fillStyle = 'rgba(139,92,246,0.8)' + ctx.fillText('✦', 0, 9) + ctx.font = '700 14px Inter, ui-sans-serif, sans-serif' + ctx.fillStyle = 'rgba(255,255,255,0.45)' + ctx.fillText('Agent Flow', 0, iconR + 22) + ctx.font = '11px Inter, ui-sans-serif, sans-serif' + ctx.fillStyle = 'rgba(255,255,255,0.2)' + ctx.fillText('Enter a task → to deploy agents', 0, iconR + 38) + } + + ctx.restore() + } + draw() + + return () => { + cancelAnimationFrame(rafRef.current) + ro.disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const handleClick = useCallback((e: React.MouseEvent) => { + if (dragRef.current.moved) return + const canvas = canvasRef.current + if (!canvas) return + const rect = canvas.getBoundingClientRect() + const cam = camRef.current + const mx = (e.clientX - rect.left - cam.x) / cam.scale + const my = (e.clientY - rect.top - cam.y) / cam.scale + let hit: string | null = null + for (const node of nodesRef.current.values()) { + if (node.type !== 'agent') continue + const dx = mx - node.x, dy = my - node.y + if (dx * dx + dy * dy <= (node.r + 8) * (node.r + 8)) { hit = node.id; break } + } + onSelectAgent(hit === selRef.current ? null : hit) + }, [onSelectAgent]) + + const handleMouseDown = (e: React.MouseEvent) => { + dragRef.current = { active: true, moved: false, sx: e.clientX, sy: e.clientY, cx: camRef.current.x, cy: camRef.current.y } + } + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragRef.current.active) return + const dx = e.clientX - dragRef.current.sx + const dy = e.clientY - dragRef.current.sy + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragRef.current.moved = true + camRef.current.x = dragRef.current.cx + dx + camRef.current.y = dragRef.current.cy + dy + } + const handleMouseUp = () => { setTimeout(() => { dragRef.current.active = false }, 50) } + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault() + const factor = e.deltaY > 0 ? 0.92 : 1.08 + camRef.current.scale = Math.max(0.25, Math.min(4, camRef.current.scale * factor)) + } + + return ( +
+ + + {/* Legend */} +
+ {[ + { color: '#3b82f6', label: 'web search' }, + { color: '#8b5cf6', label: 'llm call' }, + { color: '#22d3ee', label: 'browser' }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ + {/* Hint */} +
+ SCROLL TO ZOOM · DRAG TO PAN +
+
+ ) +} diff --git a/src/components/TimelinePanel.tsx b/src/components/TimelinePanel.tsx new file mode 100644 index 0000000..2c77450 --- /dev/null +++ b/src/components/TimelinePanel.tsx @@ -0,0 +1,124 @@ +import { type MAState, ROLE_COLORS } from '@/lib/multiAgentEngine' + +interface Props { + state: MAState +} + +const TOOL_COLORS: Record = { + web_search: '#3b82f6', + llm_call: '#8b5cf6', + browser_navigate: '#22d3ee', + browser_snapshot: '#06b6d4', + browser_read: '#0891b2', + browser_click: '#0e7490', + browser_fill: '#155e75', +} +function toolColor(tool: string) { return TOOL_COLORS[tool] ?? '#64748b' } + +export function TimelinePanel({ state }: Props) { + const { agents, phase } = state + const toolCalls = state.toolCalls ?? [] + + const activeAgents = agents.filter(a => a.startTs) + if (phase === 'idle' || activeAgents.length === 0) return null + + const now = Date.now() + const allStart = activeAgents.map(a => a.startTs!) + const allEnd = [ + ...activeAgents.filter(a => a.endTs).map(a => a.endTs!), + ...toolCalls.filter(tc => tc.endTs).map(tc => tc.endTs!), + now, + ] + const minTs = Math.min(...allStart) + const maxTs = Math.max(...allEnd, minTs + 1000) + const range = maxTs - minTs || 1 + + const pct = (ts: number) => ((ts - minTs) / range) * 100 + + return ( +
+
+ Timeline +
+ + {activeAgents.map(agent => { + const color = ROLE_COLORS[agent.role] ?? '#6366f1' + const left = pct(agent.startTs!) + const width = agent.endTs ? pct(agent.endTs) - left : 100 - left + const agentTcs = toolCalls.filter(tc => tc.agentId === agent.id && tc.startTs) + + return ( +
+ {/* Agent name label */} +
+ {agent.name} +
+ + {/* Track */} +
+ {/* Agent activity bar */} +
+ + {/* Tool call markers */} + {agentTcs.map(tc => { + const tcLeft = pct(tc.startTs) + const tcWidth = tc.endTs ? Math.max(pct(tc.endTs) - tcLeft, 0.5) : 1 + return ( +
+ ) + })} +
+ + {/* Duration */} +
+ {agent.endTs + ? `${((agent.endTs - agent.startTs!) / 1000).toFixed(1)}s` + : `${((now - agent.startTs!) / 1000).toFixed(0)}s`} +
+
+ ) + })} + + {/* Tool color legend */} +
+ {Object.entries(TOOL_COLORS).map(([tool, color]) => ( +
+
+ + {tool.replace('browser_', '')} + +
+ ))} +
+
+ ) +} diff --git a/src/components/pages/UniversePage.tsx b/src/components/pages/UniversePage.tsx index 79c6332..f4da716 100644 --- a/src/components/pages/UniversePage.tsx +++ b/src/components/pages/UniversePage.tsx @@ -6,6 +6,8 @@ import { } from '@/lib/multiAgentEngine' import { addMemory } from '@/lib/memory' import { Universe3D } from '@/components/Universe3D' +import { FlowGraph } from '@/components/FlowGraph' +import { TimelinePanel } from '@/components/TimelinePanel' import { testProviderKey, testTavilyKey, type TestResult } from '@/lib/testProviderKey' import { loadSessions, saveSession, deleteSession, type ChatSession } from '@/lib/chatHistory' @@ -819,6 +821,7 @@ export function UniversePage({ apiKey }: Props) { const [task, setTask] = useState('') const [running, setRunning] = useState(false) const [selectedId, setSelectedId] = useState(null) + const [viewMode, setViewMode] = useState<'3d' | 'flow'>('flow') const [followUp, setFollowUp] = useState('') const [savedToMemory, setSavedToMemory] = useState(false) const [copiedOutput, setCopiedOutput] = useState(false) @@ -989,6 +992,11 @@ export function UniversePage({ apiKey }: Props) { const doneCount = agents.filter(a => a.status === 'done').length const activeProviders = PROVIDER_ORDER.filter(p => providerKeys[p]) + const totalTokensIn = agents.reduce((s, a) => s + (a.tokensIn ?? 0), 0) + const totalTokensOut = agents.reduce((s, a) => s + (a.tokensOut ?? 0), 0) + const totalTokens = totalTokensIn + totalTokensOut + const totalCost = maState.totalCostUsd ?? 0 + return (
@@ -1010,10 +1018,47 @@ export function UniversePage({ apiKey }: Props) { {doneCount}/{agents.length} agents done )} + {totalTokens > 0 && ( + <> + + + {totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1)}k` : totalTokens} tokens + + {totalCost > 0 && ( + + ~${totalCost < 0.01 ? totalCost.toFixed(4) : totalCost.toFixed(3)} + + )} + + )} +
+
+ {/* View toggle */} +
+ {(['flow', '3d'] as const).map(mode => ( + + ))} +
+ {phase !== 'idle' && ( + + )}
- {phase !== 'idle' && ( - - )}
@@ -1029,13 +1074,15 @@ export function UniversePage({ apiKey }: Props) { onNew={handleReset} /> - {/* ── Universe 3D ───────────────────────────────────────────────── */} -
- + {/* ── Canvas + Timeline column ───────────────────────────────────── */} +
+
+ {viewMode === 'flow' + ? + : + } +
+
{/* ── Right Panel ───────────────────────────────────────────────── */} diff --git a/src/lib/multiAgentEngine.ts b/src/lib/multiAgentEngine.ts index da2b097..56e9cee 100644 --- a/src/lib/multiAgentEngine.ts +++ b/src/lib/multiAgentEngine.ts @@ -161,23 +161,62 @@ export interface MAAgent { dependsOn: string[] x: number y: number + startTs?: number + endTs?: number + tokensIn?: number + tokensOut?: number + costUsd?: number } export interface MAMessage { id: string; fromId: string; toId: string; content: string; ts: number } +export interface MAToolCall { + id: string + agentId: string + tool: string + label: string + status: 'running' | 'done' | 'error' + startTs: number + endTs?: number +} + export interface MAState { phase: 'idle' | 'planning' | 'executing' | 'synthesizing' | 'done' | 'error' agents: MAAgent[] messages: MAMessage[] + toolCalls: MAToolCall[] finalOutput: string errorMsg: string totalAgents: number + totalCostUsd: number } export const INITIAL_MA_STATE: MAState = { - phase: 'idle', agents: [], messages: [], finalOutput: '', errorMsg: '', totalAgents: 0, + phase: 'idle', agents: [], messages: [], toolCalls: [], finalOutput: '', errorMsg: '', totalAgents: 0, totalCostUsd: 0, +} + +// ── Token pricing (per 1M tokens) ───────────────────────────────────────────── +// [inputPer1M, outputPer1M] in USD +const TOKEN_PRICES: Record = { + 'claude-haiku-4-5': [0.80, 4.00], + 'claude-sonnet-4-6': [3.00, 15.00], + 'claude-opus-4-6': [15.00, 75.00], + 'gpt-4.1-nano': [0.10, 0.40], + 'gpt-4.1-mini': [0.40, 1.60], + 'gpt-4.1': [2.00, 8.00], + 'gemini-2.0-flash': [0.075, 0.30], + 'gemini-2.5-flash': [0.15, 0.60], + 'gemini-2.5-pro': [1.25, 5.00], + 'llama-3.1-8b-instant': [0.05, 0.08], + 'llama-3.3-70b-versatile': [0.59, 0.79], +} + +function estimateCost(modelLabel: string, tokensIn: number, tokensOut: number): number { + const prices = TOKEN_PRICES[modelLabel] + if (!prices) return 0 + return (tokensIn * prices[0] + tokensOut * prices[1]) / 1_000_000 } export type MAUpdater = (fn: (prev: MAState) => MAState) => void @@ -388,6 +427,7 @@ async function executeBrowserAgent( agent: MAAgent, keys: ProviderKeys, onOutputUpdate: (full: string) => void, + update?: MAUpdater, ): Promise<{ output: string; provider: LLMProvider; modelLabel: string }> { const apiKey = keys.anthropic ?? '' if (!apiKey) throw new Error('Browser agents require an Anthropic API key — add it in the Providers panel.') @@ -486,12 +526,20 @@ async function executeBrowserAgent( for (const block of toolBlocks) { let result = '' + const tcId = makeId() + const tcLabel = block.name === 'browser_navigate' ? String(block.input.url ?? '').slice(0, 45) + : block.name === 'browser_click' ? `click [${block.input.ref}]` + : block.name === 'browser_fill' ? `fill [${block.input.ref}]` + : block.name.replace('browser_', '') + update?.(s => ({ ...s, toolCalls: [...(s.toolCalls ?? []), { id: tcId, agentId: agent.id, tool: block.name, label: tcLabel, status: 'running' as const, startTs: Date.now() }] })) try { result = await executeToolCall(block.name, block.input) + update?.(s => ({ ...s, toolCalls: (s.toolCalls ?? []).map(tc => tc.id === tcId ? { ...tc, status: 'done' as const, endTs: Date.now() } : tc) })) } catch (e) { result = `Error: ${e instanceof Error ? e.message : String(e)}` out += `⚠ ${block.name} failed: ${result}\n` onOutputUpdate(out) + update?.(s => ({ ...s, toolCalls: (s.toolCalls ?? []).map(tc => tc.id === tcId ? { ...tc, status: 'error' as const, endTs: Date.now() } : tc) })) } toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result }) } @@ -513,6 +561,7 @@ async function executeBrowserAgent( async function streamAnthropic( modelId: string, system: string, userMsg: string, apiKey: string, maxTokens: number, onToken: (t: string) => void, + onUsage?: (tokensIn: number, tokensOut: number) => void, ): Promise { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -527,6 +576,7 @@ async function streamAnthropic( if (!res.ok) throw new Error(`Anthropic ${res.status}: ${(await res.text()).slice(0, 300)}`) if (!res.body) throw new Error('No response body') let out = '' + let tokensIn = 0, tokensOut = 0 const reader = res.body.getReader(); const dec = new TextDecoder() outer: while (true) { const { done, value } = await reader.read(); if (done) break @@ -534,11 +584,23 @@ async function streamAnthropic( if (!line.startsWith('data: ')) continue const data = line.slice(6).trim(); if (data === '[DONE]') break outer try { - const ev = JSON.parse(data) as { type: string; delta?: { text?: string } } + const ev = JSON.parse(data) as { + type: string + delta?: { text?: string } + message?: { usage?: { input_tokens?: number } } + usage?: { output_tokens?: number } + } + if (ev.type === 'message_start' && ev.message?.usage?.input_tokens) { + tokensIn = ev.message.usage.input_tokens + } + if (ev.type === 'message_delta' && ev.usage?.output_tokens) { + tokensOut = ev.usage.output_tokens + } if (ev.type === 'content_block_delta' && ev.delta?.text) { out += ev.delta.text; onToken(ev.delta.text) } } catch { /* skip */ } } } + if (onUsage && (tokensIn || tokensOut)) onUsage(tokensIn, tokensOut) return out } @@ -698,10 +760,11 @@ async function streamOpenAICompat( async function streamLLM( provider: LLMProvider, modelId: string, system: string, userMsg: string, keys: ProviderKeys, maxTokens: number, onToken: (t: string) => void, + onUsage?: (tokensIn: number, tokensOut: number) => void, ): Promise { const key = (keys as Record)[provider] ?? '' switch (provider) { - case 'anthropic': return streamAnthropic(modelId, system, userMsg, key, maxTokens, onToken) + case 'anthropic': return streamAnthropic(modelId, system, userMsg, key, maxTokens, onToken, onUsage) case 'google': return streamGoogle(modelId, system, userMsg, key, maxTokens, onToken) case 'cohere': return streamCohere(modelId, system, userMsg, key, maxTokens, onToken) case 'ollama': return streamOllama(modelId, system, userMsg, key, onToken) @@ -720,6 +783,7 @@ async function streamWithFailover( keys: ProviderKeys, availableProviders: LLMProvider[], onOutputUpdate: (fullOutput: string) => void, + onUsage?: (tokensIn: number, tokensOut: number) => void, ): Promise<{ output: string; provider: LLMProvider; modelLabel: string }> { // Order: assigned provider first, then the rest (skip providers without keys) const tryOrder = [ @@ -745,7 +809,7 @@ async function streamWithFailover( await streamLLM(provider, cfg.modelId, system, userMsg, keys, cfg.maxTokens, delta => { out += delta onOutputUpdate(out) - }) + }, onUsage) // Treat suspiciously short output as a failure (e.g. Ollama model not loaded) const textContent = out.replace(/^↻[^\n]+\n\n/, '').trim() @@ -986,7 +1050,7 @@ async function executeWorkers( if (ready.length === 0) break await Promise.all(ready.map(async agent => { - update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'thinking' } : a) })) + update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'thinking', startTs: a.startTs ?? Date.now() } : a) })) await sleep(150 + Math.random() * 250) const context = agent.dependsOn.map(d => { @@ -997,7 +1061,10 @@ async function executeWorkers( // Inject live web search results for researcher/analyst agents let webContext = '' if (tavilyKey && (agent.role === 'researcher' || agent.role === 'analyst')) { + const searchTcId = makeId() + update(s => ({ ...s, toolCalls: [...(s.toolCalls ?? []), { id: searchTcId, agentId: agent.id, tool: 'web_search', label: agent.task.slice(0, 45), status: 'running' as const, startTs: Date.now() }] })) webContext = await tavilySearch(agent.task, tavilyKey) + update(s => ({ ...s, toolCalls: (s.toolCalls ?? []).map(tc => tc.id === searchTcId ? { ...tc, status: 'done' as const, endTs: Date.now() } : tc) })) } const userMsg = [ @@ -1009,18 +1076,27 @@ async function executeWorkers( update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'working' } : a) })) + const llmTcId = makeId() + update(s => ({ ...s, toolCalls: [...(s.toolCalls ?? []), { id: llmTcId, agentId: agent.id, tool: 'llm_call', label: agent.modelLabel, status: 'running' as const, startTs: Date.now() }] })) + try { + let agentTokensIn = 0, agentTokensOut = 0 + const result = agent.role === 'browser' ? await executeBrowserAgent(agent, keys, fullOutput => { update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, output: fullOutput } : a) })) - }) + }, update) : await streamWithFailover( agent, system, userMsg, keys, availableProviders, fullOutput => { update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, output: fullOutput } : a) })) }, + (tIn, tOut) => { agentTokensIn = tIn; agentTokensOut = tOut }, ) + // Compute cost for this agent + const agentCost = estimateCost(result.modelLabel, agentTokensIn, agentTokensOut) + // If failover switched providers, update the agent's provider + modelLabel in state if (result.provider !== agent.provider) { update(s => ({ @@ -1036,12 +1112,17 @@ async function executeWorkers( })) } + update(s => ({ ...s, toolCalls: (s.toolCalls ?? []).map(tc => tc.id === llmTcId ? { ...tc, status: 'done' as const, endTs: Date.now() } : tc) })) outputs[agent.id] = result.output completed.add(agent.id); remaining.delete(agent.id) update(s => ({ ...s, - agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'done' } : a), + totalCostUsd: (s.totalCostUsd ?? 0) + agentCost, + agents: s.agents.map(a => a.id === agent.id ? { + ...a, status: 'done', endTs: Date.now(), + tokensIn: agentTokensIn, tokensOut: agentTokensOut, costUsd: agentCost, + } : a), messages: [...s.messages, { id: makeId(), fromId: agent.id, toId: 'orchestrator', content: `${agent.name} finished via ${PROVIDER_LABELS[result.provider]}`, ts: Date.now(), @@ -1049,7 +1130,10 @@ async function executeWorkers( })) } catch (e) { // All providers exhausted - update(s => ({ ...s, agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'error', output: String(e) } : a) })) + update(s => ({ ...s, + toolCalls: (s.toolCalls ?? []).map(tc => tc.id === llmTcId ? { ...tc, status: 'error' as const, endTs: Date.now() } : tc), + agents: s.agents.map(a => a.id === agent.id ? { ...a, status: 'error', output: String(e), endTs: Date.now() } : a), + })) completed.add(agent.id); remaining.delete(agent.id); return } From c250dd2a4b4a262775fd068c14ba242280772595 Mon Sep 17 00:00:00 2001 From: Dhwanil Mori <136145445+Dhwanil25@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:45:26 -0400 Subject: [PATCH 2/2] fix: add id-token: write permission to Claude review workflow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/claude-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index b71fd37..2417d5d 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -14,6 +14,7 @@ jobs: permissions: contents: read pull-requests: write + id-token: write steps: - uses: anthropics/claude-code-action@beta