From 17a22fe155dd4f9964cda783431128411f8e21b8 Mon Sep 17 00:00:00 2001
From: Shine1i
Date: Sun, 22 Feb 2026 05:18:33 +0100
Subject: [PATCH] refactor: improve layout direction handling and auxiliary
node visibility for LLMS
---
.../components/recipe-graph-aux-node.tsx | 12 +-
.../components/recipe-graph-node.tsx | 92 ++++---
.../components/rf-ui/data-edge.tsx | 41 +--
.../recipe-studio/recipe-studio-page.tsx | 15 +-
.../recipe-studio/stores/helpers/aux-sync.ts | 1 -
.../recipe-studio/stores/recipe-studio.ts | 246 +++++++++++++++++-
.../utils/graph/derive-display-graph.ts | 96 ++++++-
.../features/recipe-studio/utils/layout.ts | 17 +-
8 files changed, 431 insertions(+), 89 deletions(-)
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
index 4de17ccfc0..c7f14ebbc4 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
@@ -5,7 +5,6 @@ import { Textarea } from "@/components/ui/textarea";
import {
Handle,
NodeResizer,
- Position,
type Node,
type NodeProps,
useUpdateNodeInternals,
@@ -14,6 +13,10 @@ import { memo, type ReactElement, useEffect } from "react";
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
+import {
+ AUX_HANDLE_CLASS,
+ getAuxSourceHandlePosition,
+} from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariableEntries } from "../utils/variables";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
@@ -101,8 +104,7 @@ function AuxNodeBase({
return null;
}
- const sourcePosition =
- data.layoutDirection === "TB" ? Position.Bottom : Position.Right;
+ const sourcePosition = getAuxSourceHandlePosition(data.layoutDirection);
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
@@ -141,7 +143,7 @@ function AuxNodeBase({
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
- className="!size-2 !border-border !bg-background"
+ className={AUX_HANDLE_CLASS}
/>
);
@@ -264,7 +266,7 @@ function AuxNodeBase({
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
- className="!size-2 !border-border !bg-background"
+ className={AUX_HANDLE_CLASS}
/>
);
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
index c0b2e8b382..8967a37dcb 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
@@ -31,9 +31,11 @@ import { useRecipeStudioStore } from "../stores/recipe-studio";
import type {
RecipeNode as RecipeGraphNodeType,
LlmType,
+ LayoutDirection,
NodeConfig,
SamplerType,
} from "../types";
+import { getNodeHandleLayout, NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
@@ -240,13 +242,17 @@ function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleI
type LlmInputHandlesProps = {
items: LlmInputHandleItem[];
- isTopBottom: boolean;
+ layoutDirection: LayoutDirection;
};
-function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactElement | null {
+function LlmInputHandles({
+ items,
+ layoutDirection,
+}: LlmInputHandlesProps): ReactElement | null {
if (items.length === 0) {
return null;
}
+ const isTopBottom = layoutDirection === "TB";
if (isTopBottom) {
return (
@@ -260,7 +266,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
id={item.id}
type="target"
position={Position.Top}
- className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
+ className={NODE_HANDLE_CLASS}
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
{item.label}
@@ -278,7 +284,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
id={item.id}
type="target"
position={Position.Left}
- className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
+ className={NODE_HANDLE_CLASS}
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
@@ -301,6 +307,12 @@ function RecipeGraphNodeBase({
const config = useRecipeStudioStore((state) => state.configs[id]);
const openConfig = useRecipeStudioStore((state) => state.openConfig);
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
+ const llmAuxVisible = useRecipeStudioStore(
+ (state) => state.llmAuxVisibility[id] ?? false,
+ );
+ const setLlmAuxVisibility = useRecipeStudioStore(
+ (state) => state.setLlmAuxVisibility,
+ );
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
@@ -313,16 +325,21 @@ function RecipeGraphNodeBase({
data.kind === "sampler";
const showSemanticIn = data.kind === "llm" || data.kind === "model_config";
const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider";
- const isTopBottom = layoutDirection === "TB";
-
- const dataInPosition = isTopBottom ? Position.Top : Position.Left;
- const dataOutPosition = isTopBottom ? Position.Bottom : Position.Right;
- const semanticInPosition = isTopBottom ? Position.Left : Position.Top;
- const semanticOutPosition = isTopBottom ? Position.Right : Position.Bottom;
+ const {
+ dataInPosition,
+ dataOutPosition,
+ semanticInPosition,
+ semanticOutPosition,
+ } = getNodeHandleLayout(layoutDirection);
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
- const llmInputHandles = getLlmInputHandleItems(config);
+ const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
+ const canShowLlmAux =
+ config?.kind === "llm" &&
+ (Boolean(config.prompt.trim()) ||
+ Boolean(config.system_prompt.trim()) ||
+ Boolean((config.scores?.length ?? 0) > 0));
return (
@@ -357,23 +374,40 @@ function RecipeGraphNodeBase({
-
+
+ {canShowLlmAux && (
+
+ )}
+
+
-
+
{nodeBody}
@@ -386,7 +420,7 @@ function RecipeGraphNodeBase({
position={dataInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
- handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
+ handleClassName={NODE_HANDLE_CLASS}
/>
>
)}
@@ -408,7 +442,7 @@ function RecipeGraphNodeBase({
position={semanticInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
- handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
+ handleClassName={NODE_HANDLE_CLASS}
/>
)}
@@ -420,7 +454,7 @@ function RecipeGraphNodeBase({
position={semanticOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
- handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
+ handleClassName={NODE_HANDLE_CLASS}
/>
)}
diff --git a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx
index e4c9bc2deb..ff9368f1d3 100644
--- a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx
@@ -28,12 +28,6 @@ export function DataEdge({
}: EdgeProps): ReactElement {
const resolvedPathType = resolvePathType({
type: data.path ?? "auto",
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourcePosition,
- targetPosition,
});
const [edgePath] = getPath({
type: resolvedPathType,
@@ -117,44 +111,11 @@ function getPath({
function resolvePathType({
type,
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourcePosition,
- targetPosition,
}: {
type: "auto" | "bezier" | "smoothstep" | "step" | "straight";
- sourceX: number;
- sourceY: number;
- targetX: number;
- targetY: number;
- sourcePosition: Position;
- targetPosition: Position;
}): "bezier" | "smoothstep" | "step" | "straight" {
if (type !== "auto") {
return type;
}
-
- const isVerticalFlow =
- (sourcePosition === Position.Bottom && targetPosition === Position.Top) ||
- (sourcePosition === Position.Top && targetPosition === Position.Bottom);
- if (isVerticalFlow && Math.abs(sourceX - targetX) <= 18) {
- return "straight";
- }
-
- const isHorizontalFlow =
- (sourcePosition === Position.Right && targetPosition === Position.Left) ||
- (sourcePosition === Position.Left && targetPosition === Position.Right);
- if (isHorizontalFlow && Math.abs(sourceY - targetY) <= 18) {
- return "straight";
- }
-
- const deltaX = Math.abs(sourceX - targetX);
- const deltaY = Math.abs(sourceY - targetY);
- if (deltaX < 40 || deltaY < 40) {
- return "smoothstep";
- }
-
- return "bezier";
+ return "smoothstep";
}
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index fd6827ed53..d583ece1ea 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -90,6 +90,7 @@ export function RecipeStudioPage({
edges,
auxNodePositions,
auxNodeSizes,
+ llmAuxVisibility,
configs,
processors,
sheetView,
@@ -125,6 +126,7 @@ export function RecipeStudioPage({
edges: state.edges,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
+ llmAuxVisibility: state.llmAuxVisibility,
configs: state.configs,
processors: state.processors,
sheetView: state.sheetView,
@@ -187,8 +189,17 @@ export function RecipeStudioPage({
layoutDirection,
auxNodePositions,
auxNodeSizes,
+ llmAuxVisibility,
});
- }, [auxNodePositions, auxNodeSizes, configs, edges, layoutDirection, nodes]);
+ }, [
+ auxNodePositions,
+ auxNodeSizes,
+ configs,
+ edges,
+ layoutDirection,
+ llmAuxVisibility,
+ nodes,
+ ]);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
@@ -358,7 +369,7 @@ export function RecipeStudioPage({
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
- data: { path: "auto" },
+ data: { path: "smoothstep" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts
index 109a950b43..73cd04b4cc 100644
--- a/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts
@@ -59,4 +59,3 @@ export function syncSizesRecord(
}
return prev;
}
-
diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
index 4894905e0d..d583b7dd01 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -22,7 +22,9 @@ import {
type BlockKind,
type BlockType,
} from "../blocks/registry";
+import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
+import { HANDLE_IDS } from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
import { syncPositionsRecord, syncSizesRecord } from "./helpers/aux-sync";
@@ -43,6 +45,7 @@ type RecipeStudioState = {
edges: Edge[];
auxNodePositions: Record;
auxNodeSizes: Record;
+ llmAuxVisibility: Record;
configs: Record;
processors: RecipeProcessorConfig[];
sheetView: SheetView;
@@ -59,6 +62,7 @@ type RecipeStudioState = {
openConfig: (id: string) => void;
setLayoutDirection: (direction: LayoutDirection) => void;
applyLayout: () => void;
+ setLlmAuxVisibility: (id: string, visible: boolean) => void;
addSamplerNode: (type: SamplerType) => void;
addSeedNode: () => void;
addLlmNode: (type: LlmType) => void;
@@ -88,6 +92,7 @@ const INITIAL_STATE = {
edges: [],
auxNodePositions: {},
auxNodeSizes: {},
+ llmAuxVisibility: {},
configs: {},
processors: [],
sheetView: "root",
@@ -102,6 +107,7 @@ const INITIAL_STATE = {
| "edges"
| "auxNodePositions"
| "auxNodeSizes"
+ | "llmAuxVisibility"
| "configs"
| "processors"
| "sheetView"
@@ -127,6 +133,79 @@ function buildAddedNodeState(
return buildNodeUpdate(state, config, state.layoutDirection);
}
+function getAddedNodeContext(
+ update: Partial | RecipeStudioState,
+): {
+ nodes: RecipeNode[];
+ configs: Record;
+ newNodeId: string;
+} | null {
+ const nodes = "nodes" in update ? update.nodes : null;
+ const configs = "configs" in update ? update.configs : null;
+ const newNodeId = "activeConfigId" in update ? update.activeConfigId : null;
+ if (!(nodes && configs && newNodeId)) {
+ return null;
+ }
+ return { nodes, configs, newNodeId };
+}
+
+function placeNodeNear(
+ nodes: RecipeNode[],
+ nodeId: string,
+ anchorId: string,
+ direction: LayoutDirection,
+ relation: "before" | "after",
+): RecipeNode[] {
+ const anchor = nodes.find((node) => node.id === anchorId);
+ if (!anchor) {
+ return nodes;
+ }
+ const primaryOffset = relation === "before" ? -440 : 440;
+ return nodes.map((node) => {
+ if (node.id !== nodeId) {
+ return node;
+ }
+ if (direction === "TB") {
+ return {
+ ...node,
+ position: {
+ x: anchor.position.x,
+ y: anchor.position.y + primaryOffset,
+ },
+ };
+ }
+ return {
+ ...node,
+ position: {
+ x: anchor.position.x + primaryOffset,
+ y: anchor.position.y,
+ },
+ };
+ });
+}
+
+function connectSemantic(
+ edges: Edge[],
+ configs: Record,
+ sourceId: string,
+ targetId: string,
+): { edges: Edge[]; configs: Record } {
+ const result = applyRecipeConnection(
+ {
+ source: sourceId,
+ sourceHandle: HANDLE_IDS.semanticOut,
+ target: targetId,
+ targetHandle: HANDLE_IDS.semanticIn,
+ },
+ configs,
+ edges,
+ );
+ return {
+ edges: result.edges,
+ configs: result.configs ?? configs,
+ };
+}
+
export const useRecipeStudioStore = create((set, get) => ({
...INITIAL_STATE,
setSheetView: (view) => set({ sheetView: view }),
@@ -138,7 +217,6 @@ export const useRecipeStudioStore = create((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
- auxNodePositions: {},
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@@ -148,20 +226,68 @@ export const useRecipeStudioStore = create((set, get) => ({
applyLayout: () =>
set((state) => {
const isTopBottom = state.layoutDirection === "TB";
- const { nodes } = getLayoutedElements(state.nodes, state.edges, {
+ const displayGraph = deriveDisplayGraph({
+ nodes: state.nodes,
+ edges: state.edges,
+ configs: state.configs,
+ layoutDirection: state.layoutDirection,
+ auxNodePositions: state.auxNodePositions,
+ auxNodeSizes: state.auxNodeSizes,
+ llmAuxVisibility: state.llmAuxVisibility,
+ });
+ const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
});
+ const layoutedPositions = new Map(
+ nodes.map((node) => [node.id, node.position] as const),
+ );
+ const nextNodes = state.nodes.map((node) => {
+ const position = layoutedPositions.get(node.id);
+ if (!position) {
+ return node;
+ }
+ return { ...node, position };
+ });
+ const nextAuxNodePositions: Record = {};
+ for (const auxId of displayGraph.auxNodeIds) {
+ const existing = state.auxNodePositions[auxId];
+ const layouted = layoutedPositions.get(auxId);
+ if (layouted) {
+ nextAuxNodePositions[auxId] = layouted;
+ continue;
+ }
+ if (existing) {
+ nextAuxNodePositions[auxId] = existing;
+ continue;
+ }
+ const fallback = displayGraph.auxDefaults[auxId];
+ if (fallback) {
+ nextAuxNodePositions[auxId] = fallback;
+ }
+ }
return {
- auxNodePositions: {},
+ auxNodePositions: nextAuxNodePositions,
nodes: applyLayoutDirectionToNodes(
- nodes,
+ nextNodes,
state.configs,
state.layoutDirection,
),
};
}),
+ setLlmAuxVisibility: (id, visible) =>
+ set((state) => {
+ if (state.llmAuxVisibility[id] === visible) {
+ return state;
+ }
+ return {
+ llmAuxVisibility: {
+ ...state.llmAuxVisibility,
+ [id]: visible,
+ },
+ };
+ }),
addSamplerNode: (type) =>
set((state) => buildAddedNodeState(state, "sampler", type)),
addSeedNode: () =>
@@ -183,9 +309,94 @@ export const useRecipeStudioStore = create((set, get) => ({
}),
addLlmNode: (type) => set((state) => buildAddedNodeState(state, "llm", type)),
addModelProviderNode: () =>
- set((state) => buildAddedNodeState(state, "llm", "model_provider")),
+ set((state) => {
+ const added = buildAddedNodeState(state, "llm", "model_provider");
+ const context = getAddedNodeContext(added);
+ if (!context) {
+ return added;
+ }
+ let { nodes, configs } = context;
+ let edges = state.edges;
+ const unboundModelConfigs = Object.values(configs).filter(
+ (config) =>
+ config.kind === "model_config" &&
+ !config.provider.trim(),
+ );
+ if (unboundModelConfigs.length > 0) {
+ nodes = placeNodeNear(
+ nodes,
+ context.newNodeId,
+ unboundModelConfigs[0].id,
+ state.layoutDirection,
+ "before",
+ );
+ }
+ if (unboundModelConfigs.length === 1) {
+ const next = connectSemantic(
+ edges,
+ configs,
+ context.newNodeId,
+ unboundModelConfigs[0].id,
+ );
+ edges = next.edges;
+ configs = next.configs;
+ }
+ return { ...added, nodes, edges, configs };
+ }),
addModelConfigNode: () =>
- set((state) => buildAddedNodeState(state, "llm", "model_config")),
+ set((state) => {
+ const added = buildAddedNodeState(state, "llm", "model_config");
+ const context = getAddedNodeContext(added);
+ if (!context) {
+ return added;
+ }
+ let { nodes, configs } = context;
+ let edges = state.edges;
+ const providers = Object.values(configs).filter(
+ (config) => config.kind === "model_provider",
+ );
+ const unboundLlms = Object.values(configs).filter(
+ (config) => config.kind === "llm" && !config.model_alias.trim(),
+ );
+ if (providers.length === 1) {
+ nodes = placeNodeNear(
+ nodes,
+ context.newNodeId,
+ providers[0].id,
+ state.layoutDirection,
+ "after",
+ );
+ } else if (unboundLlms.length > 0) {
+ nodes = placeNodeNear(
+ nodes,
+ context.newNodeId,
+ unboundLlms[0].id,
+ state.layoutDirection,
+ "before",
+ );
+ }
+ if (providers.length === 1) {
+ const next = connectSemantic(
+ edges,
+ configs,
+ providers[0].id,
+ context.newNodeId,
+ );
+ edges = next.edges;
+ configs = next.configs;
+ }
+ if (unboundLlms.length === 1) {
+ const next = connectSemantic(
+ edges,
+ configs,
+ context.newNodeId,
+ unboundLlms[0].id,
+ );
+ edges = next.edges;
+ configs = next.configs;
+ }
+ return { ...added, nodes, edges, configs };
+ }),
addExpressionNode: () =>
set((state) => buildAddedNodeState(state, "expression", "expression")),
loadRecipe: (snapshot) =>
@@ -202,6 +413,7 @@ export const useRecipeStudioStore = create((set, get) => ({
nextY: snapshot.nextY,
auxNodePositions: {},
auxNodeSizes: {},
+ llmAuxVisibility: {},
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
@@ -237,7 +449,12 @@ export const useRecipeStudioStore = create((set, get) => ({
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults);
- return next === state.auxNodePositions ? state : { auxNodePositions: next };
+ if (next === state.auxNodePositions) {
+ return state;
+ }
+ return {
+ auxNodePositions: next,
+ };
}),
syncAuxNodeSizes: (activeIds) =>
set((state) => {
@@ -293,7 +510,20 @@ export const useRecipeStudioStore = create((set, get) => ({
removedIds,
);
const nodes = applyNodeChanges(changes, state.nodes);
- return { nodes, edges: removed.edges, configs: removed.configs };
+ const llmAuxVisibility =
+ removedIds.length === 0
+ ? state.llmAuxVisibility
+ : Object.fromEntries(
+ Object.entries(state.llmAuxVisibility).filter(
+ ([id]) => !removedIds.includes(id),
+ ),
+ );
+ return {
+ nodes,
+ edges: removed.edges,
+ configs: removed.configs,
+ llmAuxVisibility,
+ };
};
set(applyNodesChange);
},
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
index e47e2b04eb..ab7b0202ed 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
@@ -13,6 +13,7 @@ type DisplayGraphInput = {
layoutDirection: LayoutDirection;
auxNodePositions: Record;
auxNodeSizes: Record;
+ llmAuxVisibility: Record;
};
export type DisplayGraph = {
@@ -29,6 +30,7 @@ function normalizeEdge(edge: Edge, configs: Record): Edge {
return {
...edge,
type: "canvas",
+ data: { ...(edge.data ?? {}), path: "smoothstep" },
style: { ...baseStyle, ...(edge.style ?? {}) },
};
}
@@ -43,6 +45,7 @@ function normalizeEdge(edge: Edge, configs: Record): Edge {
return {
...edge,
type: semantic ? "semantic" : "canvas",
+ data: semantic ? edge.data : { ...(edge.data ?? {}), path: "smoothstep" },
...handles,
style: { ...baseStyle, ...(edge.style ?? {}) },
};
@@ -54,6 +57,68 @@ type AuxNodeItem = {
data: RecipeGraphAuxNodeData;
};
+type Rect = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+function toRect(
+ position: XYPosition,
+ width: number,
+ height: number,
+): Rect {
+ return {
+ x: position.x,
+ y: position.y,
+ width,
+ height,
+ };
+}
+
+function intersects(a: Rect, b: Rect, pad = 18): boolean {
+ return !(
+ a.x + a.width + pad <= b.x ||
+ b.x + b.width + pad <= a.x ||
+ a.y + a.height + pad <= b.y ||
+ b.y + b.height + pad <= a.y
+ );
+}
+
+function findNonOverlappingPosition(
+ preferred: XYPosition,
+ width: number,
+ height: number,
+ direction: LayoutDirection,
+ occupied: Rect[],
+): XYPosition {
+ const primaryStep =
+ direction === "TB"
+ ? { x: 0, y: -(height + 24) }
+ : { x: -(width + 24), y: 0 };
+ const lateralUnit =
+ direction === "TB"
+ ? { x: Math.max(48, Math.round(width * 0.3)), y: 0 }
+ : { x: 0, y: Math.max(40, Math.round(height * 0.35)) };
+ const lateralPattern = [0, 1, -1, 2, -2];
+
+ for (let ring = 0; ring <= 8; ring += 1) {
+ for (const lateral of lateralPattern) {
+ const candidate = {
+ x: preferred.x + primaryStep.x * ring + lateralUnit.x * lateral,
+ y: preferred.y + primaryStep.y * ring + lateralUnit.y * lateral,
+ };
+ const rect = toRect(candidate, width, height);
+ if (!occupied.some((other) => intersects(rect, other))) {
+ return candidate;
+ }
+ }
+ }
+
+ return preferred;
+}
+
export function deriveDisplayGraph({
nodes,
edges,
@@ -61,6 +126,7 @@ export function deriveDisplayGraph({
layoutDirection,
auxNodePositions,
auxNodeSizes,
+ llmAuxVisibility,
}: DisplayGraphInput): DisplayGraph {
const displayNodes = nodes.map((node) => {
const hasWidth =
@@ -80,12 +146,22 @@ export function deriveDisplayGraph({
const auxEdges: Edge[] = [];
const auxDefaults: Record = {};
const auxNodeIds: string[] = [];
+ const occupiedRects: Rect[] = displayNodes.map((node) =>
+ toRect(
+ node.position,
+ readNodeWidth(node) ?? DEFAULT_NODE_WIDTH,
+ readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT,
+ ),
+ );
for (const node of displayNodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
}
+ if (!llmAuxVisibility[config.id]) {
+ continue;
+ }
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const items: AuxNodeItem[] = [];
@@ -160,10 +236,17 @@ export function deriveDisplayGraph({
let xCursor = startX;
for (const entry of itemsWithLayout) {
- const defaultPosition = {
+ const preferredPosition = {
x: xCursor,
y: node.position.y - entry.height - sideOffset,
};
+ const defaultPosition = findNonOverlappingPosition(
+ preferredPosition,
+ entry.width,
+ entry.height,
+ llmDirection,
+ occupiedRects,
+ );
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
xCursor += entry.width + gap;
@@ -171,6 +254,7 @@ export function deriveDisplayGraph({
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
+ occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,
@@ -212,10 +296,17 @@ export function deriveDisplayGraph({
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
for (const entry of itemsWithLayout) {
- const defaultPosition = {
+ const preferredPosition = {
x: baseX + (maxWidth - entry.width),
y: yCursor,
};
+ const defaultPosition = findNonOverlappingPosition(
+ preferredPosition,
+ entry.width,
+ entry.height,
+ llmDirection,
+ occupiedRects,
+ );
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
yCursor += entry.height + gap;
@@ -223,6 +314,7 @@ export function deriveDisplayGraph({
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
+ occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,
diff --git a/studio/frontend/src/features/recipe-studio/utils/layout.ts b/studio/frontend/src/features/recipe-studio/utils/layout.ts
index 30ce0243d6..da3ffb5cd8 100644
--- a/studio/frontend/src/features/recipe-studio/utils/layout.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/layout.ts
@@ -6,6 +6,7 @@ type LayoutOptions = {
direction?: LayoutDirection;
nodesep?: number;
ranksep?: number;
+ edgesep?: number;
nodeWidth?: number;
nodeHeight?: number;
};
@@ -19,13 +20,20 @@ export function getLayoutedElements(
direction = "LR",
nodesep = 80,
ranksep = 80,
+ edgesep = 28,
nodeWidth = 220,
nodeHeight = 64,
} = options;
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
- graph.setGraph({ rankdir: direction, nodesep, ranksep });
+ graph.setGraph({
+ rankdir: direction,
+ nodesep,
+ ranksep,
+ edgesep,
+ ranker: "network-simplex",
+ });
nodes.forEach((node) => {
const width = node.measured?.width ?? nodeWidth;
@@ -34,7 +42,12 @@ export function getLayoutedElements(
});
edges.forEach((edge) => {
- graph.setEdge(edge.source, edge.target);
+ const semantic = edge.type === "semantic";
+ const aux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-");
+ graph.setEdge(edge.source, edge.target, {
+ minlen: semantic ? 1 : 1,
+ weight: semantic ? 10 : aux ? 1 : 3,
+ });
});
dagre.layout(graph);