diff --git a/apps/agent-forge/package.json b/apps/agent-forge/package.json index 0e3e2f939..dce307073 100644 --- a/apps/agent-forge/package.json +++ b/apps/agent-forge/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@loushy/build-ai-agent": "file:../..", + "@xyflow/react": "^12.12.0", + "dagre": "^0.8.5", "react": "^18.3.1", "react-dom": "^18.3.1", "yaml": "^2.9.1" @@ -21,6 +23,7 @@ "devDependencies": { "@ai-sdk/anthropic": "^0.0.42", "@ai-sdk/openai": "^0.0.42", + "@types/dagre": "^0.7.52", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx new file mode 100644 index 000000000..6a94fe6e8 --- /dev/null +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -0,0 +1,120 @@ +import { memo, useState } from 'react'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; +import type { AgentGraphNode, AgentGraphNodeType } from '../graph/types'; + +/** + * Custom ReactFlow node renderer for every `AgentGraphNodeType`, styled to + * match `.design-ref/agent-forge-mockup.html`'s `.node` chrome: a colored + * type-swatch header, a body of key/value config rows read straight from + * the node's `AgentGraphSpec` data (nothing hardcoded per-node), and + * left/right ports drawn with ReactFlow's own `` so pipeline + * connections attach to a real, draggable anchor rather than a picture of + * one. + * + * One component + a `type` field handles all five kinds rather than five + * near-duplicate components, since the only real differences are the + * swatch color/icon, which ports are shown, and which data rows to render. + */ + +const NODE_META: Record = { + trigger: { color: 'var(--info)', icon: 'โšก', hasIn: false, hasOut: true }, + llm: { color: 'var(--accent)', icon: '๐Ÿง ', hasIn: true, hasOut: true }, + tool: { color: 'var(--warning)', icon: '๐Ÿ”ง', hasIn: true, hasOut: true }, + approval: { color: 'var(--warning)', icon: 'โœ“', hasIn: true, hasOut: true }, + output: { color: 'var(--success)', icon: 'โ†’', hasIn: true, hasOut: false }, +}; + +function rowsFor(node: AgentGraphNode): { k: string; v: string }[] { + switch (node.type) { + case 'trigger': + return [{ k: 'type', v: node.data.trigger.type || '(unset)' }]; + case 'llm': + return [ + { k: 'provider', v: node.data.provider.type || '(unset)' }, + { k: 'model', v: node.data.provider.model || '(unset)' }, + ]; + case 'tool': + return [{ k: 'tool', v: node.data.toolName || '(unset)' }]; + case 'approval': + return [{ k: 'requiresApproval', v: node.data.policy.requiresApproval ? 'yes' : 'no' }]; + case 'output': + return []; + } +} + +export interface AgentNodeData extends Record { + graphNode: AgentGraphNode; + onRename: (nodeId: string, label: string) => void; +} + +function AgentNodeImpl({ data, selected }: NodeProps) { + const { graphNode, onRename } = data as unknown as AgentNodeData; + const meta = NODE_META[graphNode.type]; + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(graphNode.label); + + function commitRename() { + setEditing(false); + const trimmed = draft.trim(); + if (trimmed && trimmed !== graphNode.label) onRename(graphNode.id, trimmed); + else setDraft(graphNode.label); + } + + return ( +
+ {meta.hasIn && } + {meta.hasOut && } +
+ + {meta.icon} + + {editing ? ( + setDraft(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + setDraft(graphNode.label); + setEditing(false); + } + }} + style={{ padding: '2px 6px', fontSize: 12 }} + /> + ) : ( + { + setDraft(graphNode.label); + setEditing(true); + }} + title="Double-click to rename" + > + {graphNode.label} + + )} +
+
+ {rowsFor(graphNode).map((row) => ( +
+ {row.k} + {row.v} +
+ ))} +
+
+ ); +} + +export const AgentNode = memo(AgentNodeImpl); + +export const NODE_TYPES = { + trigger: AgentNode, + llm: AgentNode, + tool: AgentNode, + approval: AgentNode, + output: AgentNode, +}; diff --git a/apps/agent-forge/src/canvas/__tests__/graphMutations.test.ts b/apps/agent-forge/src/canvas/__tests__/graphMutations.test.ts new file mode 100644 index 000000000..3b7c87ddf --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/graphMutations.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { + addNode, + connectNodes, + duplicateNode, + moveNode, + removeEdge, + removeNode, + renameNode, + updateNodeData, +} from '../graphMutations'; +import type { AgentGraphSpec } from '../../graph/types'; + +function baseGraph(): AgentGraphSpec { + return { + version: 1, + nodes: [ + { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + label: 'Trigger', + data: { trigger: { type: 'input' } }, + }, + { + id: 'llm-1', + type: 'llm', + position: { x: 200, y: 0 }, + label: 'agent', + data: { name: 'agent', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } }, + }, + { + id: 'tool-1', + type: 'tool', + position: { x: 400, y: 0 }, + label: 'http', + data: { toolName: 'http' }, + }, + ], + edges: [ + { id: 'e1', source: 'trigger-1', target: 'llm-1' }, + { id: 'e2', source: 'llm-1', target: 'tool-1' }, + ], + }; +} + +describe('addNode', () => { + it('appends a new node of the given type with default data at the given position', () => { + const g = addNode(baseGraph(), 'output', { x: 600, y: 0 }); + expect(g.nodes).toHaveLength(4); + const added = g.nodes[3]; + expect(added.type).toBe('output'); + expect(added.position).toEqual({ x: 600, y: 0 }); + expect(g.edges).toHaveLength(2); // adding a node never adds edges + }); + + it('does not mutate the input graph', () => { + const original = baseGraph(); + const originalNodeCount = original.nodes.length; + addNode(original, 'output', { x: 0, y: 0 }); + expect(original.nodes).toHaveLength(originalNodeCount); + }); +}); + +describe('removeNode', () => { + it('removes the node and any edge touching it', () => { + const g = removeNode(baseGraph(), 'llm-1'); + expect(g.nodes.map((n) => n.id)).toEqual(['trigger-1', 'tool-1']); + expect(g.edges).toEqual([]); + }); + + it('is a no-op for an unknown node id', () => { + const g = removeNode(baseGraph(), 'ghost'); + expect(g.nodes).toHaveLength(3); + expect(g.edges).toHaveLength(2); + }); +}); + +describe('duplicateNode', () => { + it('clones a node with a new id, offset position, no inherited edges', () => { + const g = duplicateNode(baseGraph(), 'tool-1'); + expect(g.nodes).toHaveLength(4); + const clone = g.nodes[3]; + expect(clone.id).not.toBe('tool-1'); + expect(clone.type).toBe('tool'); + expect(clone.position).toEqual({ x: 440, y: 40 }); + expect(g.edges.some((e) => e.source === clone.id || e.target === clone.id)).toBe(false); + }); + + it('deep-clones data so editing the clone does not affect the source', () => { + const g = duplicateNode(baseGraph(), 'tool-1'); + const clone = g.nodes[3]; + expect(clone.type).toBe('tool'); + if (clone.type === 'tool') { + expect(clone.data).not.toBe(baseGraph().nodes[2].data); + } + }); + + it('is a no-op for an unknown node id', () => { + const g = duplicateNode(baseGraph(), 'ghost'); + expect(g.nodes).toHaveLength(3); + }); +}); + +describe('renameNode', () => { + it('renames a node label', () => { + const g = renameNode(baseGraph(), 'tool-1', 'HTTP call'); + expect(g.nodes.find((n) => n.id === 'tool-1')?.label).toBe('HTTP call'); + }); + + it('also updates data.name for an llm node', () => { + const g = renameNode(baseGraph(), 'llm-1', 'renamed-agent'); + const llm = g.nodes.find((n) => n.id === 'llm-1'); + expect(llm?.label).toBe('renamed-agent'); + expect(llm?.type === 'llm' && llm.data.name).toBe('renamed-agent'); + }); +}); + +describe('moveNode', () => { + it('updates a node position', () => { + const g = moveNode(baseGraph(), 'tool-1', { x: 999, y: 111 }); + expect(g.nodes.find((n) => n.id === 'tool-1')?.position).toEqual({ x: 999, y: 111 }); + }); +}); + +describe('updateNodeData', () => { + it('shallow-merges a data patch', () => { + const g = updateNodeData(baseGraph(), 'tool-1', { toolName: 'day-name' }); + const tool = g.nodes.find((n) => n.id === 'tool-1'); + expect(tool?.type === 'tool' && tool.data.toolName).toBe('day-name'); + }); +}); + +describe('connectNodes', () => { + it('rejects a connection that runs against the pipeline direction (tool -> llm)', () => { + const result = connectNodes(baseGraph(), 'tool-1', 'llm-1'); + expect(result.ok).toBe(false); + }); + + it('accepts a compatible connection (llm -> tool)', () => { + const g = removeNode(baseGraph(), 'tool-1'); // drop the pre-existing tool node + const withTool = addNode(g, 'tool', { x: 400, y: 0 }); + const newToolId = withTool.nodes[withTool.nodes.length - 1].id; + const result = connectNodes(withTool, 'llm-1', newToolId); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.graph.edges.some((e) => e.source === 'llm-1' && e.target === newToolId)).toBe(true); + } + }); + + it('rejects an incompatible connection (trigger -> trigger)', () => { + const g = addNode(baseGraph(), 'trigger', { x: 0, y: 200 }); + const secondTrigger = g.nodes[g.nodes.length - 1].id; + const result = connectNodes(g, 'trigger-1', secondTrigger); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/not a valid pipeline connection/); + }); + + it('rejects connecting a node to itself', () => { + const result = connectNodes(baseGraph(), 'llm-1', 'llm-1'); + expect(result.ok).toBe(false); + }); + + it('rejects an already-existing connection', () => { + const result = connectNodes(baseGraph(), 'trigger-1', 'llm-1'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/already connected/); + }); + + it('rejects a connection referencing an unknown node', () => { + const result = connectNodes(baseGraph(), 'ghost', 'llm-1'); + expect(result.ok).toBe(false); + }); +}); + +describe('removeEdge', () => { + it('removes a single edge by id', () => { + const g = removeEdge(baseGraph(), 'e1'); + expect(g.edges).toEqual([{ id: 'e2', source: 'llm-1', target: 'tool-1' }]); + }); +}); diff --git a/apps/agent-forge/src/canvas/__tests__/layout.test.ts b/apps/agent-forge/src/canvas/__tests__/layout.test.ts new file mode 100644 index 000000000..bb61c8b0f --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/layout.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { autoLayout } from '../layout'; +import type { AgentGraphSpec } from '../../graph/types'; + +function graph(): AgentGraphSpec { + return { + version: 1, + nodes: [ + { id: 'trigger-1', type: 'trigger', position: { x: 0, y: 0 }, label: 'Trigger', data: { trigger: { type: 'input' } } }, + { + id: 'llm-1', + type: 'llm', + position: { x: 0, y: 0 }, + label: 'agent', + data: { name: 'agent', prompt: 'hi', provider: { type: 'mock', model: 'mock-1' } }, + }, + { id: 'tool-1', type: 'tool', position: { x: 0, y: 0 }, label: 'http', data: { toolName: 'http' } }, + ], + edges: [ + { id: 'e1', source: 'trigger-1', target: 'llm-1' }, + { id: 'e2', source: 'llm-1', target: 'tool-1' }, + ], + }; +} + +describe('autoLayout', () => { + it('spreads nodes out left-to-right in pipeline order', () => { + const laidOut = autoLayout(graph()); + const byId = Object.fromEntries(laidOut.nodes.map((n) => [n.id, n.position])); + expect(byId['trigger-1'].x).toBeLessThan(byId['llm-1'].x); + expect(byId['llm-1'].x).toBeLessThan(byId['tool-1'].x); + }); + + it('preserves node identity, type and data - only position changes', () => { + const original = graph(); + const laidOut = autoLayout(original); + expect(laidOut.nodes.map((n) => n.id)).toEqual(original.nodes.map((n) => n.id)); + expect(laidOut.nodes.map((n) => n.data)).toEqual(original.nodes.map((n) => n.data)); + expect(laidOut.edges).toEqual(original.edges); + }); + + it('is a no-op on an empty graph', () => { + const empty: AgentGraphSpec = { version: 1, nodes: [], edges: [] }; + expect(autoLayout(empty)).toEqual(empty); + }); +}); diff --git a/apps/agent-forge/src/canvas/dnd.ts b/apps/agent-forge/src/canvas/dnd.ts new file mode 100644 index 000000000..72e8cd529 --- /dev/null +++ b/apps/agent-forge/src/canvas/dnd.ts @@ -0,0 +1,6 @@ +/** + * Shared drag-and-drop MIME type for the LeftRail "Nodes" palette -> + * CanvasArea drop target (LOU-M1). Kept as one constant so the producer + * (LeftRail) and consumer (CanvasArea) can't drift out of sync. + */ +export const PALETTE_DRAG_MIME = 'application/agent-forge-node-type'; diff --git a/apps/agent-forge/src/canvas/graphMutations.ts b/apps/agent-forge/src/canvas/graphMutations.ts new file mode 100644 index 000000000..e4290c24d --- /dev/null +++ b/apps/agent-forge/src/canvas/graphMutations.ts @@ -0,0 +1,166 @@ +import type { + AgentGraphEdge, + AgentGraphNode, + AgentGraphNodeType, + AgentGraphSpec, + GraphPosition, +} from '../graph/types'; +import { isEdgeTypeAllowed } from '../graph/connectionRules'; + +/** + * Pure, canvas-agnostic mutations over `AgentGraphSpec` (LOU-M2). + * + * `CanvasArea` calls these in response to ReactFlow interaction events, but + * none of these functions know anything about ReactFlow: they take the + * app's canonical `AgentGraphSpec` and return a new one. That's the seam + * the epic's constraint asks for - "the ReactFlow instance's own internal + * state should be a view over your app's canonical graph state, not a + * second source of truth" - and it's what makes these easy to unit test + * without mounting a canvas at all. + */ + +let idCounter = 0; +/** Overridable so tests can assert on deterministic ids. */ +export function nextNodeId(type: AgentGraphNodeType): string { + idCounter += 1; + return `${type}-${Date.now().toString(36)}-${idCounter}`; +} + +export function defaultNodeData(type: AgentGraphNodeType): AgentGraphNode['data'] { + switch (type) { + case 'trigger': + return { trigger: { type: 'input' } }; + case 'llm': + return { name: 'new-step', prompt: '', provider: { type: 'mock', model: 'mock-1' } }; + case 'tool': + return { toolName: '' }; + case 'approval': + return { policy: { requiresApproval: true } }; + case 'output': + return {}; + } +} + +export function defaultNodeLabel(type: AgentGraphNodeType): string { + switch (type) { + case 'trigger': + return 'Trigger'; + case 'llm': + return 'LLM step'; + case 'tool': + return 'Tool'; + case 'approval': + return 'Human approval'; + case 'output': + return 'Output'; + } +} + +/** Adds a new node of `type` at `position` with sensible default data. */ +export function addNode(graph: AgentGraphSpec, type: AgentGraphNodeType, position: GraphPosition): AgentGraphSpec { + const id = nextNodeId(type); + const node = { + id, + type, + position, + label: defaultNodeLabel(type), + data: defaultNodeData(type), + } as AgentGraphNode; + return { ...graph, nodes: [...graph.nodes, node] }; +} + +/** Removes a node and any edge touching it. */ +export function removeNode(graph: AgentGraphSpec, nodeId: string): AgentGraphSpec { + return { + ...graph, + nodes: graph.nodes.filter((n) => n.id !== nodeId), + edges: graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId), + }; +} + +/** Clones a node (new id, offset position, same data/type) with no edges. */ +export function duplicateNode(graph: AgentGraphSpec, nodeId: string): AgentGraphSpec { + const source = graph.nodes.find((n) => n.id === nodeId); + if (!source) return graph; + const id = nextNodeId(source.type); + const clone = { + ...source, + id, + label: `${source.label} copy`, + position: { x: source.position.x + 40, y: source.position.y + 40 }, + data: structuredClone(source.data), + } as AgentGraphNode; + return { ...graph, nodes: [...graph.nodes, clone] }; +} + +/** Renames a node's display label (and, for an `llm` node, its `data.name`). */ +export function renameNode(graph: AgentGraphSpec, nodeId: string, label: string): AgentGraphSpec { + return { + ...graph, + nodes: graph.nodes.map((n) => { + if (n.id !== nodeId) return n; + if (n.type === 'llm') { + return { ...n, label, data: { ...n.data, name: label } }; + } + return { ...n, label }; + }), + }; +} + +/** Moves a node to a new canvas position (e.g. after a ReactFlow drag). */ +export function moveNode(graph: AgentGraphSpec, nodeId: string, position: GraphPosition): AgentGraphSpec { + return { + ...graph, + nodes: graph.nodes.map((n) => (n.id === nodeId ? { ...n, position } : n)), + }; +} + +/** + * Shallow-merges a data patch into a node, preserving its discriminant + * `type`. Untyped (`Record`) rather than generic over + * `AgentGraphNode` - callers (Inspector.tsx) already narrow `selected` by + * `selected.type` before building the patch, so the patch shape is + * type-checked at the call site; a type parameter here can't be inferred + * from the patch object alone since `AgentGraphNode['data']` is a union. + */ +export function updateNodeData(graph: AgentGraphSpec, nodeId: string, patch: Record): AgentGraphSpec { + return { + ...graph, + nodes: graph.nodes.map((n) => (n.id === nodeId ? ({ ...n, data: { ...n.data, ...patch } } as AgentGraphNode) : n)), + }; +} + +export type ConnectResult = + | { ok: true; graph: AgentGraphSpec } + | { ok: false; reason: string }; + +/** + * Attempts to connect two nodes by id. Rejects (without mutating) when + * either node is missing, the pair is already connected, or the port + * types are incompatible per connectionRules.ts - the same rule + * validateGraph() enforces, applied here so an invalid drag is rejected + * inline instead of only at save/validate time. + */ +export function connectNodes(graph: AgentGraphSpec, sourceId: string, targetId: string): ConnectResult { + if (sourceId === targetId) { + return { ok: false, reason: "Can't connect a node to itself" }; + } + const source = graph.nodes.find((n) => n.id === sourceId); + const target = graph.nodes.find((n) => n.id === targetId); + if (!source || !target) { + return { ok: false, reason: 'Unknown source or target node' }; + } + if (!isEdgeTypeAllowed(source.type, target.type)) { + return { ok: false, reason: `Can't connect ${source.type} โ†’ ${target.type}: not a valid pipeline connection` }; + } + const alreadyConnected = graph.edges.some((e) => e.source === sourceId && e.target === targetId); + if (alreadyConnected) { + return { ok: false, reason: 'Nodes are already connected' }; + } + const edge: AgentGraphEdge = { id: `edge-${sourceId}-${targetId}-${idCounter++}`, source: sourceId, target: targetId }; + return { ok: true, graph: { ...graph, edges: [...graph.edges, edge] } }; +} + +export function removeEdge(graph: AgentGraphSpec, edgeId: string): AgentGraphSpec { + return { ...graph, edges: graph.edges.filter((e) => e.id !== edgeId) }; +} diff --git a/apps/agent-forge/src/canvas/layout.ts b/apps/agent-forge/src/canvas/layout.ts new file mode 100644 index 000000000..35fd7e7d9 --- /dev/null +++ b/apps/agent-forge/src/canvas/layout.ts @@ -0,0 +1,43 @@ +import dagre from 'dagre'; +import type { AgentGraphSpec } from '../graph/types'; + +const NODE_WIDTH = 190; +const NODE_HEIGHT = 96; + +/** + * Re-lays-out the current graph left-to-right with `dagre` (rank + * direction LR matches the pipeline's natural + * trigger -> llm -> tool -> approval -> output reading order). Only + * touches node `position` - node/edge identity and data are untouched, so + * this is safe to run on every "Auto layout" toolbar click without losing + * anything but manual positioning. + */ +export function autoLayout(graph: AgentGraphSpec): AgentGraphSpec { + if (graph.nodes.length === 0) return graph; + + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 90 }); + g.setDefaultEdgeLabel(() => ({})); + + for (const node of graph.nodes) { + g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + } + for (const edge of graph.edges) { + if (graph.nodes.some((n) => n.id === edge.source) && graph.nodes.some((n) => n.id === edge.target)) { + g.setEdge(edge.source, edge.target); + } + } + + dagre.layout(g); + + return { + ...graph, + nodes: graph.nodes.map((node) => { + const laidOut = g.node(node.id); + if (!laidOut) return node; + // dagre positions are node centers; AgentGraphNode.position is + // top-left, matching how the canvas places nodes. + return { ...node, position: { x: laidOut.x - NODE_WIDTH / 2, y: laidOut.y - NODE_HEIGHT / 2 } }; + }), + }; +} diff --git a/apps/agent-forge/src/canvas/templates.ts b/apps/agent-forge/src/canvas/templates.ts new file mode 100644 index 000000000..4bfd9b3f7 --- /dev/null +++ b/apps/agent-forge/src/canvas/templates.ts @@ -0,0 +1,53 @@ +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { specToGraph } from '../graph/specToGraph'; +import type { AgentGraphSpec } from '../graph/types'; + +export type TemplateId = 'blank' | 'support-bot'; + +export interface AgentTemplate { + id: TemplateId; + name: string; + description: string; + spec: AgentSpec; +} + +/** + * Seed templates for the "New agent" flow (LOU-M3). `support-bot` mirrors + * the shape (and prompt) of this SDK's own examples/support-bot/index.ts, + * extended with a trigger and a tool node so it demonstrates the full + * trigger -> llm -> tool -> output pipeline shape on the canvas - the + * example itself is a minimal `createAgent()` call with no triggers/tools, + * since those are graph-editor-only concepts layered on top of the same + * flat `AgentSpec`. + */ +export const AGENT_TEMPLATES: AgentTemplate[] = [ + { + id: 'blank', + name: 'Blank graph', + description: 'A single LLM node with the mock provider - the minimum AgentSpec needs.', + spec: { + name: 'new-agent', + prompt: 'You are a helpful agent.', + provider: { type: 'mock', model: 'mock-1' }, + }, + }, + { + id: 'support-bot', + name: 'Support bot', + description: 'trigger โ†’ llm โ†’ tool โ†’ output, seeded from examples/support-bot.', + spec: { + name: 'support-bot', + prompt: + 'You are a friendly customer support agent. Be concise, empathetic, and always ask for ' + + 'an order number when a customer reports a problem with an order.', + provider: { type: 'mock', model: 'mock-1' }, + tools: ['http'], + triggers: [{ type: 'input' }], + }, + }, +]; + +export function graphFromTemplate(id: TemplateId): AgentGraphSpec { + const template = AGENT_TEMPLATES.find((t) => t.id === id) ?? AGENT_TEMPLATES[0]; + return specToGraph(template.spec); +} diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index 30a22999f..250225b1e 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -1,17 +1,244 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { DragEvent } from 'react'; +import { + Background, + BackgroundVariant, + Controls, + MiniMap, + ReactFlow, + ReactFlowProvider, + useReactFlow, + type Connection, + type Edge, + type EdgeChange, + type Node, + type NodeChange, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { useAppState } from '../state/AppState'; +import { NODE_TYPES, type AgentNodeData } from '../canvas/AgentNode'; +import { + addNode, + connectNodes, + duplicateNode, + moveNode, + removeEdge, + removeNode, + renameNode, +} from '../canvas/graphMutations'; +import { autoLayout } from '../canvas/layout'; +import { isEdgeTypeAllowed } from '../graph/connectionRules'; +import { PALETTE_DRAG_MIME } from '../canvas/dnd'; +import type { AgentGraphNodeType, AgentGraphSpec } from '../graph/types'; + +function toRfNodes( + graph: AgentGraphSpec, + selectedNodeId: string | undefined, + onRename: (id: string, label: string) => void +): Node[] { + return graph.nodes.map((n) => ({ + id: n.id, + type: n.type, + position: n.position, + selected: n.id === selectedNodeId, + data: { graphNode: n, onRename }, + })); +} + +function toRfEdges(graph: AgentGraphSpec): Edge[] { + return graph.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })); +} + /** - * Canvas placeholder (LOU-L1). Real ReactFlow-based node-graph editing - * lands in LOU-M - this just occupies the layout's center column with a - * styled placeholder so the app shell is complete without pretending to be - * an interactive canvas. + * Real ReactFlow canvas (LOU-M1/M2). `AppState.graph` is the single source + * of truth: every handler below reads it and, on a real mutation, calls + * `setGraph()` - ReactFlow's `nodes`/`edges` props are recomputed from + * `graph` on every render rather than kept in separate ReactFlow-owned + * state, so there is nowhere for the two to drift apart. */ -export function CanvasArea() { +function CanvasInner() { + const { graph, setGraph, selectedNodeId, setSelectedNodeId } = useAppState(); + const { screenToFlowPosition, fitView } = useReactFlow(); + const [connectError, setConnectError] = useState(undefined); + const errorTimer = useRef>(); + + function flashError(reason: string) { + setConnectError(reason); + if (errorTimer.current) clearTimeout(errorTimer.current); + errorTimer.current = setTimeout(() => setConnectError(undefined), 3200); + } + + const onRename = useCallback( + (nodeId: string, label: string) => { + setGraph((g) => renameNode(g, nodeId, label)); + }, + [setGraph] + ); + + const nodes = useMemo(() => toRfNodes(graph, selectedNodeId, onRename), [graph, selectedNodeId, onRename]); + const edges = useMemo(() => toRfEdges(graph), [graph]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const positionChanges = changes.filter((c) => c.type === 'position' && c.position); + if (positionChanges.length > 0) { + setGraph((g) => { + let next = g; + for (const change of positionChanges) { + if (change.type === 'position' && change.position) { + next = moveNode(next, change.id, change.position); + } + } + return next; + }); + } + const selectChange = [...changes].reverse().find((c) => c.type === 'select'); + if (selectChange && selectChange.type === 'select') { + setSelectedNodeId(selectChange.selected ? selectChange.id : undefined); + } + }, + [setGraph, setSelectedNodeId] + ); + + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => { + const removed = changes.filter((c) => c.type === 'remove'); + if (removed.length === 0) return; + setGraph((g) => removed.reduce((acc, c) => (c.type === 'remove' ? removeEdge(acc, c.id) : acc), g)); + }, + [setGraph] + ); + + const onNodesDelete = useCallback( + (deleted: Node[]) => { + setGraph((g) => deleted.reduce((acc, n) => removeNode(acc, n.id), g)); + if (deleted.some((n) => n.id === selectedNodeId)) setSelectedNodeId(undefined); + }, + [setGraph, selectedNodeId, setSelectedNodeId] + ); + + const onConnect = useCallback( + (connection: Connection) => { + if (!connection.source || !connection.target) return; + let rejection: string | undefined; + setGraph((g) => { + const result = connectNodes(g, connection.source as string, connection.target as string); + if (!result.ok) { + rejection = result.reason; + return g; + } + return result.graph; + }); + if (rejection) flashError(rejection); + }, + [setGraph] + ); + + const isValidConnection = useCallback( + (connection: Connection | Edge) => { + const source = graph.nodes.find((n) => n.id === connection.source); + const target = graph.nodes.find((n) => n.id === connection.target); + if (!source || !target) return false; + return isEdgeTypeAllowed(source.type, target.type); + }, + [graph] + ); + + const onDragOver = useCallback((e: DragEvent) => { + if (!e.dataTransfer.types.includes(PALETTE_DRAG_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (e: DragEvent) => { + const nodeType = e.dataTransfer.getData(PALETTE_DRAG_MIME) as AgentGraphNodeType | ''; + if (!nodeType) return; + e.preventDefault(); + const position = screenToFlowPosition({ x: e.clientX, y: e.clientY }); + setGraph((g) => addNode(g, nodeType, position)); + }, + [screenToFlowPosition, setGraph] + ); + + function handleAutoLayout() { + setGraph((g) => autoLayout(g)); + window.requestAnimationFrame(() => fitView({ padding: 0.2, duration: 200 })); + } + + function handleFitView() { + fitView({ padding: 0.2, duration: 200 }); + } + + const handleDuplicate = useCallback(() => { + if (!selectedNodeId) return; + setGraph((g) => duplicateNode(g, selectedNodeId)); + }, [selectedNodeId, setGraph]); + + // Ctrl/Cmd+D duplicates the selected node - a discoverable shortcut + // alongside the toolbar button, matching common canvas-editor conventions. + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') { + e.preventDefault(); + handleDuplicate(); + } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleDuplicate]); + return ( -
-
-
Canvas
- The node-graph editor (ReactFlow) is built in LOU-M. This placeholder confirms the app shell's layout, - theming and data model without a real canvas yet. +
+
+ + +
+ {connectError &&
{connectError}
} + + + + +
); } + +/** + * Real ReactFlow-based node-graph editor (LOU-M), replacing the LOU-L1 + * placeholder. `@xyflow/react` is this app's chosen package - `reactflow` + * on npm is the predecessor name, now in maintenance mode pointing + * adopters at `@xyflow/react` (same team, actively released); see the + * epic's PR description for the full rationale. + */ +export function CanvasArea() { + return ( + + + + ); +} diff --git a/apps/agent-forge/src/components/Inspector.tsx b/apps/agent-forge/src/components/Inspector.tsx index 6900f81e9..61bed94dc 100644 --- a/apps/agent-forge/src/components/Inspector.tsx +++ b/apps/agent-forge/src/components/Inspector.tsx @@ -1,99 +1,141 @@ import { useAppState } from '../state/AppState'; +import { updateNodeData, renameNode } from '../canvas/graphMutations'; -const KNOWN_TOOLS = ['http', 'current-date', 'day-name']; const KNOWN_PROVIDERS = ['mock', 'openai', 'anthropic', 'ollama', 'openrouter']; /** - * Inspector fields here are limited to what `AgentSpec` actually has a - * field for (name/prompt/provider/tools/policy.requiresApproval) - the - * mockup also shows per-node fields like temperature, max-steps and hook - * wiring that don't exist in `AgentSpec` yet (those are richer per-node - * config LOU-M's real graph editor will need to add to the spec/schema - * first). This deliberately edits the single underlying `AgentSpec` - * directly rather than a selected canvas node, since there is no real - * canvas/node-selection yet (LOU-M). + * Inspector (LOU-M): reflects and edits whichever canvas node is currently + * selected, wired through `AppState.graph`/`selectedNodeId` - the same + * mechanism the canvas itself reads/writes (see CanvasArea.tsx), so there + * is exactly one source of truth for node data, per the epic's + * "no second state system" constraint. Field sets are still limited to + * what `AgentGraphNode['data']` actually carries per node type (see + * graph/types.ts); richer per-node config (temperature, max-steps, hook + * wiring) needs `AgentSpec` itself to grow those fields first. */ export function Inspector() { - const { spec, setSpec } = useAppState(); + const { graph, setGraph, selectedNodeId } = useAppState(); + const selected = graph.nodes.find((n) => n.id === selectedNodeId); - function toggleTool(tool: string) { - setSpec((prev) => { - const tools = prev.tools ?? []; - const has = tools.includes(tool); - return { ...prev, tools: has ? tools.filter((t) => t !== tool) : [...tools, tool] }; - }); + if (!selected) { + return ( +
+
+
Inspector
+
No node selected
+
+
+
Select a node on the canvas to view and edit its configuration.
+
+
+ ); + } + + const selectedId = selected.id; + function patch(data: Record) { + setGraph((g) => updateNodeData(g, selectedId, data)); } return (
-
Selected agent
-
{spec.name}
+
Selected node ({selected.type})
+
{selected.label}
- + setSpec((prev) => ({ ...prev, name: e.target.value }))} + value={selected.label} + onChange={(e) => setGraph((g) => renameNode(g, selected.id, e.target.value))} />
-
- -
- + + {selected.type === 'llm' && ( + <> +
+ +
+ + patch({ provider: { ...selected.data.provider, model: e.target.value } })} + /> +
+
+
+ +