mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
feat: improve graph readability with colored edges, bezier curves, and auto-layout
- Color-code edges by semantic role: structural edges (system↔node) in muted zinc, agent-to-agent edges in indigo for clear visual separation - Switch from smoothstep to bezier curves for smoother, more natural edge routing that reduces right-angle overlaps - Stagger agent nodes at alternating X offsets in concurrent, handoff, and group-chat layouts so parallel fan-out/fan-in edges take distinct bezier paths instead of stacking - Increase spacing in all layouts to give edges more room - Add dagre-powered auto-layout button (top-right of canvas) that computes optimal left-to-right hierarchical node positions - Add 2 new tests for auto-layout across all orchestration modes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "kopaya",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
@@ -71,6 +72,10 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@dagrejs/dagre": ["@dagrejs/dagre@3.0.0", "", { "dependencies": { "@dagrejs/graphlib": "4.0.1" } }, "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q=="],
|
||||
|
||||
"@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.1", "", {}, "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA=="],
|
||||
|
||||
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"vite": "7.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
MarkerType,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
@@ -13,12 +16,14 @@ import {
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
|
||||
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addHandoffEdge,
|
||||
autoLayoutGraph,
|
||||
fromCanvasPositions,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
@@ -38,13 +43,14 @@ interface PatternGraphCanvasProps {
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
export function PatternGraphCanvas({
|
||||
function PatternGraphCanvasInner({
|
||||
pattern,
|
||||
availableModels,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
}: PatternGraphCanvasProps) {
|
||||
const { fitView } = useReactFlow();
|
||||
const graph = useMemo(() => resolvePatternGraph(pattern), [pattern]);
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
@@ -144,6 +150,13 @@ export function PatternGraphCanvas({
|
||||
onNodeSelect(null);
|
||||
}, [onNodeSelect]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
onGraphChange(layouted);
|
||||
// Allow React to render the new positions before fitting
|
||||
requestAnimationFrame(() => fitView({ padding: 0.3 }));
|
||||
}, [graph, onGraphChange, fitView]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full rounded-xl border border-zinc-800 bg-zinc-950/50">
|
||||
<ReactFlow
|
||||
@@ -164,15 +177,34 @@ export function PatternGraphCanvas({
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
|
||||
type: 'default',
|
||||
style: { stroke: '#6366f1', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#6366f1' },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
||||
<Panel position="top-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAutoLayout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800/90 px-2.5 py-1.5 text-[11px] font-medium text-zinc-300 shadow-sm backdrop-blur transition hover:border-zinc-600 hover:bg-zinc-700/90 hover:text-zinc-100"
|
||||
title="Auto-layout nodes"
|
||||
>
|
||||
<LayoutGrid className="size-3.5" />
|
||||
Auto layout
|
||||
</button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatternGraphCanvas(props: PatternGraphCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<PatternGraphCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,17 +115,37 @@ function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph:
|
||||
return sourceNode?.kind === 'agent' && targetNode?.kind === 'agent';
|
||||
}
|
||||
|
||||
const EDGE_COLORS = {
|
||||
structural: { stroke: '#3f3f46', markerColor: '#3f3f46' }, // zinc-700 — muted
|
||||
agentToAgent: { stroke: '#6366f1', markerColor: '#6366f1' }, // indigo-500 — distinct
|
||||
};
|
||||
|
||||
function resolveEdgeColor(edge: PatternGraphEdge, graph: PatternGraph): typeof EDGE_COLORS.structural {
|
||||
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
||||
|
||||
if (sourceNode?.kind === 'agent' && targetNode?.kind === 'agent') {
|
||||
return EDGE_COLORS.agentToAgent;
|
||||
}
|
||||
|
||||
return EDGE_COLORS.structural;
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||
return graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
animated: mode === 'handoff',
|
||||
deletable: isEdgeDeletable(edge, mode, graph),
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
}));
|
||||
return graph.edges.map((edge) => {
|
||||
const color = resolveEdgeColor(edge, graph);
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
animated: mode === 'handoff',
|
||||
deletable: isEdgeDeletable(edge, mode, graph),
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: color.markerColor },
|
||||
style: { stroke: color.stroke, strokeWidth: 1.5 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function fromCanvasPositions(
|
||||
@@ -324,3 +344,49 @@ export function findAgentForNode(
|
||||
|
||||
return agents.find((a) => a.id === node.agentId);
|
||||
}
|
||||
|
||||
/* ── Auto-layout via dagre ─────────────────────────────────── */
|
||||
|
||||
import dagre from '@dagrejs/dagre';
|
||||
|
||||
const NODE_WIDTH = 170;
|
||||
const NODE_HEIGHT = 52;
|
||||
|
||||
/**
|
||||
* Re-compute all node positions using dagre's layered layout algorithm.
|
||||
* Direction is always left-to-right to match the graph flow.
|
||||
* Returns a new graph with updated positions; edges are unchanged.
|
||||
*/
|
||||
export function autoLayoutGraph(graph: PatternGraph): PatternGraph {
|
||||
const g = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({
|
||||
rankdir: 'LR',
|
||||
nodesep: 60,
|
||||
ranksep: 120,
|
||||
marginx: 20,
|
||||
marginy: 20,
|
||||
});
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const layoutedNodes = graph.nodes.map((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: Math.round(dagreNode.x - NODE_WIDTH / 2),
|
||||
y: Math.round(dagreNode.y - NODE_HEIGHT / 2),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges: graph.edges };
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ function spreadY(index: number, count: number, gap = 170): number {
|
||||
}
|
||||
|
||||
function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const xStep = 250;
|
||||
const inputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userInput,
|
||||
kind: 'user-input',
|
||||
@@ -148,10 +149,10 @@ function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 220 * Math.max(agents.length + 1, 2), y: 0 },
|
||||
position: { x: xStep * Math.max(agents.length + 1, 2), y: 0 },
|
||||
};
|
||||
const agentNodes = agents.map((agent, index) =>
|
||||
createAgentNode(agent, index, { x: 220 * (index + 1), y: 0 }),
|
||||
createAgentNode(agent, index, { x: xStep * (index + 1), y: 0 }),
|
||||
);
|
||||
const edges: PatternGraphEdge[] = [];
|
||||
const path = [inputNode.id, ...agentNodes.map((node) => node.id), outputNode.id];
|
||||
@@ -166,6 +167,8 @@ function createLinearGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
}
|
||||
|
||||
function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const agentCount = Math.max(agents.length, 1);
|
||||
const agentGap = 150;
|
||||
const inputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userInput,
|
||||
kind: 'user-input',
|
||||
@@ -174,20 +177,25 @@ function createConcurrentGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const distributorNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.distributor,
|
||||
kind: 'distributor',
|
||||
position: { x: 190, y: 0 },
|
||||
position: { x: 200, y: 0 },
|
||||
};
|
||||
const collectorNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.collector,
|
||||
kind: 'collector',
|
||||
position: { x: 650, y: 0 },
|
||||
position: { x: 700, y: 0 },
|
||||
};
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 860, y: 0 },
|
||||
position: { x: 920, y: 0 },
|
||||
};
|
||||
// Stagger agents at alternating X offsets so fan-out/fan-in edges
|
||||
// take distinct bezier paths and don't overlap each other.
|
||||
const agentNodes = agents.map((agent, index) =>
|
||||
createAgentNode(agent, index, { x: 430, y: spreadY(index, Math.max(agents.length, 1), 170) }),
|
||||
createAgentNode(agent, index, {
|
||||
x: index % 2 === 0 ? 420 : 480,
|
||||
y: spreadY(index, agentCount, agentGap),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -210,19 +218,19 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 700, y: 0 },
|
||||
position: { x: 780, y: 0 },
|
||||
};
|
||||
const entryAgent = agents[0];
|
||||
const specialistCount = Math.max(agents.length - 1, 1);
|
||||
const entryNode = entryAgent
|
||||
? createAgentNode(entryAgent, 0, { x: 200, y: 0 })
|
||||
? createAgentNode(entryAgent, 0, { x: 220, y: 0 })
|
||||
: undefined;
|
||||
// Place specialists in a vertical column with enough spacing so
|
||||
// bidirectional edges to/from the triage agent don't overlap.
|
||||
// Place specialists in a staggered column with wider horizontal and vertical
|
||||
// spacing so bidirectional bezier edges between triage↔specialists route cleanly.
|
||||
const specialistNodes = agents.slice(1).map((agent, index) =>
|
||||
createAgentNode(agent, index + 1, {
|
||||
x: 460,
|
||||
y: spreadY(index, specialistCount, 150),
|
||||
x: index % 2 === 0 ? 500 : 560,
|
||||
y: spreadY(index, specialistCount, 160),
|
||||
}),
|
||||
);
|
||||
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
|
||||
@@ -243,6 +251,7 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
}
|
||||
|
||||
function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const agentCount = Math.max(agents.length, 1);
|
||||
const inputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userInput,
|
||||
kind: 'user-input',
|
||||
@@ -251,19 +260,20 @@ function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const orchestratorNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.orchestrator,
|
||||
kind: 'orchestrator',
|
||||
position: { x: 200, y: 0 },
|
||||
position: { x: 220, y: 0 },
|
||||
};
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 660, y: 0 },
|
||||
position: { x: 740, y: 0 },
|
||||
};
|
||||
// Place agents in a vertical column to the right of the orchestrator.
|
||||
// This avoids crossing edges from the bidirectional orchestrator↔agent links.
|
||||
// Place agents in a staggered column to the right of the orchestrator.
|
||||
// Alternating X offsets give bezier curves distinct paths so bidirectional
|
||||
// orchestrator↔agent edges don't stack on top of each other.
|
||||
const agentNodes = agents.map((agent, index) =>
|
||||
createAgentNode(agent, index, {
|
||||
x: 440,
|
||||
y: spreadY(index, Math.max(agents.length, 1), 130),
|
||||
x: index % 2 === 0 ? 460 : 520,
|
||||
y: spreadY(index, agentCount, 140),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } fr
|
||||
import {
|
||||
addAgentNodeToGraph,
|
||||
addHandoffEdge,
|
||||
autoLayoutGraph,
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
isConnectionAllowed,
|
||||
@@ -301,3 +302,35 @@ describe('edge deletion rules', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-layout', () => {
|
||||
test('autoLayoutGraph repositions nodes without changing edges', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
|
||||
expect(layouted.nodes.length).toBe(graph.nodes.length);
|
||||
expect(layouted.edges.length).toBe(graph.edges.length);
|
||||
expect(layouted.edges).toEqual(graph.edges);
|
||||
|
||||
const positionsChanged = layouted.nodes.some((n, i) => {
|
||||
const orig = graph.nodes[i]!;
|
||||
return n.position.x !== orig.position.x || n.position.y !== orig.position.y;
|
||||
});
|
||||
expect(positionsChanged).toBe(true);
|
||||
});
|
||||
|
||||
test('autoLayoutGraph produces finite positions for all modes', () => {
|
||||
for (const mode of ['sequential', 'concurrent', 'handoff', 'group-chat'] as const) {
|
||||
const pattern = findPattern(mode);
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
|
||||
expect(layouted.nodes.length).toBe(graph.nodes.length);
|
||||
for (const node of layouted.nodes) {
|
||||
expect(Number.isFinite(node.position.x)).toBe(true);
|
||||
expect(Number.isFinite(node.position.y)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user