mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +02:00
feat: directional edges and provider icons on agent nodes
- Add ArrowClosed markers to all graph edges for clear directional flow - Replace generic Bot icon with AI provider logos (OpenAI, Anthropic, Google) on agent nodes, inferred from the agent's model id - Show model display name as subtitle on agent nodes (e.g. 'GPT-5.4', 'Claude Opus 4.5') - Thread availableModels catalog from PatternEditor through canvas to resolve friendly model names; falls back to raw model id - Add 3 new tests for arrow markers, provider icons, and model labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -318,6 +318,7 @@ export function PatternEditor({
|
|||||||
<div className="min-h-[300px] flex-1 px-5 py-3">
|
<div className="min-h-[300px] flex-1 px-5 py-3">
|
||||||
<PatternGraphCanvas
|
<PatternGraphCanvas
|
||||||
pattern={pattern}
|
pattern={pattern}
|
||||||
|
availableModels={availableModels}
|
||||||
onGraphChange={emitGraphChange}
|
onGraphChange={emitGraphChange}
|
||||||
onNodeSelect={setSelectedNodeId}
|
onNodeSelect={setSelectedNodeId}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { CircleUser, Bot, Shuffle, Layers, Radio } from 'lucide-react';
|
import { CircleUser, Shuffle, Layers, Radio, Bot } from 'lucide-react';
|
||||||
|
|
||||||
import type { GraphNodeData } from '@renderer/lib/patternGraph';
|
import type { GraphNodeData } from '@renderer/lib/patternGraph';
|
||||||
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
import type { PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||||
|
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||||
|
|
||||||
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
|
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
|
||||||
'user-input': CircleUser,
|
'user-input': CircleUser,
|
||||||
'user-output': CircleUser,
|
'user-output': CircleUser,
|
||||||
agent: Bot,
|
agent: Bot, // fallback when no provider is resolved
|
||||||
distributor: Shuffle,
|
distributor: Shuffle,
|
||||||
collector: Layers,
|
collector: Layers,
|
||||||
orchestrator: Radio,
|
orchestrator: Radio,
|
||||||
@@ -24,23 +25,30 @@ const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; tex
|
|||||||
};
|
};
|
||||||
|
|
||||||
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
|
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
|
||||||
const Icon = kindIcons[data.kind] ?? Bot;
|
|
||||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||||
const isAgent = data.kind === 'agent';
|
const isAgent = data.kind === 'agent';
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (isAgent && data.provider) {
|
||||||
|
return <ProviderIcon provider={data.provider} className="size-4 shrink-0" />;
|
||||||
|
}
|
||||||
|
const FallbackIcon = kindIcons[data.kind] ?? Bot;
|
||||||
|
return <FallbackIcon className={`size-4 shrink-0 ${colors.text}`} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
|
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
|
||||||
colors.bg
|
colors.bg
|
||||||
} ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`}
|
} ${selected ? 'ring-2 ring-indigo-500/50' : ''} ${colors.border}`}
|
||||||
>
|
>
|
||||||
<Icon className={`size-4 shrink-0 ${colors.text}`} />
|
{renderIcon()}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
<div className={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||||
{data.label}
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
{isAgent && typeof data.order === 'number' && (
|
{isAgent && data.modelLabel && (
|
||||||
<div className="text-[10px] text-zinc-500">#{data.order + 1}</div>
|
<div className="truncate text-[10px] text-zinc-500">{data.modelLabel}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{data.readOnly && (
|
{data.readOnly && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
MarkerType,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -15,6 +16,7 @@ import '@xyflow/react/dist/style.css';
|
|||||||
|
|
||||||
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||||
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import {
|
import {
|
||||||
addHandoffEdge,
|
addHandoffEdge,
|
||||||
fromCanvasPositions,
|
fromCanvasPositions,
|
||||||
@@ -30,6 +32,7 @@ import { graphNodeTypes } from './GraphNodes';
|
|||||||
|
|
||||||
interface PatternGraphCanvasProps {
|
interface PatternGraphCanvasProps {
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
|
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||||
onGraphChange: (graph: PatternGraph) => void;
|
onGraphChange: (graph: PatternGraph) => void;
|
||||||
onNodeSelect: (nodeId: string | null) => void;
|
onNodeSelect: (nodeId: string | null) => void;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
@@ -37,6 +40,7 @@ interface PatternGraphCanvasProps {
|
|||||||
|
|
||||||
export function PatternGraphCanvas({
|
export function PatternGraphCanvas({
|
||||||
pattern,
|
pattern,
|
||||||
|
availableModels,
|
||||||
onGraphChange,
|
onGraphChange,
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
@@ -45,7 +49,7 @@ export function PatternGraphCanvas({
|
|||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||||
toCanvasNodes(graph, pattern.agents),
|
toCanvasNodes(graph, pattern.agents, availableModels),
|
||||||
);
|
);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||||
toCanvasEdges(graph, pattern.mode),
|
toCanvasEdges(graph, pattern.mode),
|
||||||
@@ -53,9 +57,9 @@ export function PatternGraphCanvas({
|
|||||||
|
|
||||||
// Sync canvas when pattern changes externally
|
// Sync canvas when pattern changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodes(toCanvasNodes(graph, pattern.agents));
|
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
|
||||||
setEdges(toCanvasEdges(graph, pattern.mode));
|
setEdges(toCanvasEdges(graph, pattern.mode));
|
||||||
}, [graph, pattern.agents, pattern.mode, setNodes, setEdges]);
|
}, [graph, pattern.agents, pattern.mode, availableModels, setNodes, setEdges]);
|
||||||
|
|
||||||
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
|
const handleNodesChange: OnNodesChange<Node<GraphNodeData>> = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
@@ -162,6 +166,7 @@ export function PatternGraphCanvas({
|
|||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
|
||||||
}}
|
}}
|
||||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||||
deleteKeyCode="Delete"
|
deleteKeyCode="Delete"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Node, Edge, Connection } from '@xyflow/react';
|
import { MarkerType, type Node, type Edge, type Connection } from '@xyflow/react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OrchestrationMode,
|
OrchestrationMode,
|
||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
PatternGraphNodeKind,
|
PatternGraphNodeKind,
|
||||||
} from '@shared/domain/pattern';
|
} from '@shared/domain/pattern';
|
||||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||||
|
import type { ModelProvider } from '@shared/domain/models';
|
||||||
|
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
||||||
|
|
||||||
/* ── Canvas node data ──────────────────────────────────────── */
|
/* ── Canvas node data ──────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ export interface GraphNodeData extends Record<string, unknown> {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
/** AI provider inferred from the agent's model (agent nodes only). */
|
||||||
|
provider?: ModelProvider;
|
||||||
|
/** Short display name for the agent's model (agent nodes only). */
|
||||||
|
modelLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── View-model projection ─────────────────────────────────── */
|
/* ── View-model projection ─────────────────────────────────── */
|
||||||
@@ -57,22 +63,42 @@ function resolveNodeType(kind: PatternGraphNodeKind): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
export function toCanvasNodes(
|
||||||
return graph.nodes.map((node) => ({
|
graph: PatternGraph,
|
||||||
id: node.id,
|
agents: PatternAgentDefinition[],
|
||||||
type: resolveNodeType(node.kind),
|
models?: ReadonlyArray<ModelDefinition>,
|
||||||
position: { x: node.position.x, y: node.position.y },
|
): Node<GraphNodeData>[] {
|
||||||
data: {
|
return graph.nodes.map((node) => {
|
||||||
label: resolveNodeLabel(node, agents),
|
let provider: ModelProvider | undefined;
|
||||||
kind: node.kind,
|
let modelLabel: string | undefined;
|
||||||
agentId: node.agentId,
|
|
||||||
order: node.order,
|
if (node.kind === 'agent' && node.agentId) {
|
||||||
readOnly: isSystemNode(node.kind),
|
const agent = agents.find((a) => a.id === node.agentId);
|
||||||
},
|
if (agent?.model) {
|
||||||
draggable: true,
|
provider = inferProvider(agent.model);
|
||||||
selectable: true,
|
const modelDef = models ? findModel(agent.model, models) : undefined;
|
||||||
deletable: false,
|
modelLabel = modelDef?.name ?? agent.model;
|
||||||
}));
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
type: resolveNodeType(node.kind),
|
||||||
|
position: { x: node.position.x, y: node.position.y },
|
||||||
|
data: {
|
||||||
|
label: resolveNodeLabel(node, agents),
|
||||||
|
kind: node.kind,
|
||||||
|
agentId: node.agentId,
|
||||||
|
order: node.order,
|
||||||
|
readOnly: isSystemNode(node.kind),
|
||||||
|
provider,
|
||||||
|
modelLabel,
|
||||||
|
},
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
deletable: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Determines whether user-created edges can be deleted in this mode. */
|
/** Determines whether user-created edges can be deleted in this mode. */
|
||||||
@@ -97,6 +123,8 @@ export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edg
|
|||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: mode === 'handoff',
|
animated: mode === 'handoff',
|
||||||
deletable: isEdgeDeletable(edge, mode, graph),
|
deletable: isEdgeDeletable(edge, mode, graph),
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
|
||||||
|
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,4 +258,46 @@ describe('edge deletion rules', () => {
|
|||||||
expect(inputNode!.type).toBe('userInputNode');
|
expect(inputNode!.type).toBe('userInputNode');
|
||||||
expect(outputNode!.type).toBe('userOutputNode');
|
expect(outputNode!.type).toBe('userOutputNode');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('edges have directional arrow markers', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const edges = toCanvasEdges(graph, pattern.mode);
|
||||||
|
|
||||||
|
expect(edges.length).toBeGreaterThan(0);
|
||||||
|
for (const edge of edges) {
|
||||||
|
expect(edge.markerEnd).toBeDefined();
|
||||||
|
expect((edge.markerEnd as { type: string }).type).toBe('arrowclosed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent nodes include provider and model label when models catalog is provided', () => {
|
||||||
|
const { modelCatalog } = require('@shared/domain/models');
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const nodes = toCanvasNodes(graph, pattern.agents, modelCatalog);
|
||||||
|
|
||||||
|
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||||
|
expect(agentNodes.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const node of agentNodes) {
|
||||||
|
expect(node.data.provider).toBeDefined();
|
||||||
|
expect(node.data.modelLabel).toBeDefined();
|
||||||
|
expect(node.data.modelLabel!.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent nodes infer provider from model id without models catalog', () => {
|
||||||
|
const pattern = findPattern('sequential');
|
||||||
|
const graph = resolvePatternGraph(pattern);
|
||||||
|
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||||
|
|
||||||
|
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||||
|
for (const node of agentNodes) {
|
||||||
|
// Provider should still be inferred from model id prefix
|
||||||
|
expect(node.data.provider).toBeDefined();
|
||||||
|
// Without catalog, modelLabel falls back to the raw model id
|
||||||
|
expect(node.data.modelLabel).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user