mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +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">
|
||||
<PatternGraphCanvas
|
||||
pattern={pattern}
|
||||
availableModels={availableModels}
|
||||
onGraphChange={emitGraphChange}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
selectedNodeId={selectedNodeId}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { memo } from '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 { PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
|
||||
const kindIcons: Record<PatternGraphNodeKind, typeof CircleUser> = {
|
||||
'user-input': CircleUser,
|
||||
'user-output': CircleUser,
|
||||
agent: Bot,
|
||||
agent: Bot, // fallback when no provider is resolved
|
||||
distributor: Shuffle,
|
||||
collector: Layers,
|
||||
orchestrator: Radio,
|
||||
@@ -24,23 +25,30 @@ const kindColors: Record<PatternGraphNodeKind, { bg: string; border: string; tex
|
||||
};
|
||||
|
||||
function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: boolean }) {
|
||||
const Icon = kindIcons[data.kind] ?? Bot;
|
||||
const colors = kindColors[data.kind] ?? kindColors.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 (
|
||||
<div
|
||||
className={`flex min-w-[120px] items-center gap-2 rounded-xl border px-3 py-2 shadow-md transition ${
|
||||
colors.bg
|
||||
} ${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={`truncate text-[12px] font-semibold ${colors.text}`}>
|
||||
{data.label}
|
||||
</div>
|
||||
{isAgent && typeof data.order === 'number' && (
|
||||
<div className="text-[10px] text-zinc-500">#{data.order + 1}</div>
|
||||
{isAgent && data.modelLabel && (
|
||||
<div className="truncate text-[10px] text-zinc-500">{data.modelLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
{data.readOnly && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
@@ -15,6 +16,7 @@ import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addHandoffEdge,
|
||||
fromCanvasPositions,
|
||||
@@ -30,6 +32,7 @@ import { graphNodeTypes } from './GraphNodes';
|
||||
|
||||
interface PatternGraphCanvasProps {
|
||||
pattern: PatternDefinition;
|
||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
onNodeSelect: (nodeId: string | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
@@ -37,6 +40,7 @@ interface PatternGraphCanvasProps {
|
||||
|
||||
export function PatternGraphCanvas({
|
||||
pattern,
|
||||
availableModels,
|
||||
onGraphChange,
|
||||
onNodeSelect,
|
||||
selectedNodeId,
|
||||
@@ -45,7 +49,7 @@ export function PatternGraphCanvas({
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(
|
||||
toCanvasNodes(graph, pattern.agents),
|
||||
toCanvasNodes(graph, pattern.agents, availableModels),
|
||||
);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(
|
||||
toCanvasEdges(graph, pattern.mode),
|
||||
@@ -53,9 +57,9 @@ export function PatternGraphCanvas({
|
||||
|
||||
// Sync canvas when pattern changes externally
|
||||
useEffect(() => {
|
||||
setNodes(toCanvasNodes(graph, pattern.agents));
|
||||
setNodes(toCanvasNodes(graph, pattern.agents, availableModels));
|
||||
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(
|
||||
(changes) => {
|
||||
@@ -162,6 +166,7 @@ export function PatternGraphCanvas({
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: '#52525b' },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
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 {
|
||||
OrchestrationMode,
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
PatternGraphNodeKind,
|
||||
} 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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -19,6 +21,10 @@ export interface GraphNodeData extends Record<string, unknown> {
|
||||
agentId?: string;
|
||||
order?: number;
|
||||
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 ─────────────────────────────────── */
|
||||
@@ -57,22 +63,42 @@ function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
||||
return graph.nodes.map((node) => ({
|
||||
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),
|
||||
},
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: false,
|
||||
}));
|
||||
export function toCanvasNodes(
|
||||
graph: PatternGraph,
|
||||
agents: PatternAgentDefinition[],
|
||||
models?: ReadonlyArray<ModelDefinition>,
|
||||
): Node<GraphNodeData>[] {
|
||||
return graph.nodes.map((node) => {
|
||||
let provider: ModelProvider | undefined;
|
||||
let modelLabel: string | undefined;
|
||||
|
||||
if (node.kind === 'agent' && node.agentId) {
|
||||
const agent = agents.find((a) => a.id === node.agentId);
|
||||
if (agent?.model) {
|
||||
provider = inferProvider(agent.model);
|
||||
const modelDef = models ? findModel(agent.model, models) : undefined;
|
||||
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. */
|
||||
@@ -97,6 +123,8 @@ export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edg
|
||||
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 },
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user