feat(recipe-studio): optimize model infra auto-layout handles and centering

This commit is contained in:
Shine1i 2026-02-26 11:07:07 +01:00
commit d1047646a9
4 changed files with 552 additions and 13 deletions

View file

@ -0,0 +1,397 @@
import type { Edge, XYPosition } from "@xyflow/react";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
import type { LayoutDirection, NodeConfig, RecipeNode } from "../../types";
import { HANDLE_IDS, normalizeRecipeHandleId } from "../../utils/handles";
import { readNodeHeight, readNodeWidth } from "../../utils/rf-node-dimensions";
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type Bounds = {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
function toRect(node: RecipeNode): Rect {
return {
x: node.position.x,
y: node.position.y,
width: readNodeWidth(node) ?? DEFAULT_NODE_WIDTH,
height: readNodeHeight(node) ?? DEFAULT_NODE_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,
occupied: Rect[],
): XYPosition {
const step = 24;
for (let ring = 0; ring <= 16; ring += 1) {
for (let dx = -ring; dx <= ring; dx += 1) {
for (let dy = -ring; dy <= ring; dy += 1) {
if (ring > 0 && Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
continue;
}
const candidate = {
x: preferred.x + dx * step,
y: preferred.y + dy * step,
};
const rect = {
x: candidate.x,
y: candidate.y,
width,
height,
};
if (!occupied.some((item) => intersects(rect, item))) {
return candidate;
}
}
}
}
return preferred;
}
function isProviderToConfigEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
const source = configs[edge.source];
const target = configs[edge.target];
return source?.kind === "model_provider" && target?.kind === "model_config";
}
function isConfigToLlmEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
const source = configs[edge.source];
const target = configs[edge.target];
return source?.kind === "model_config" && target?.kind === "llm";
}
function usageKey(nodeId: string, handleId: string): string {
return `${nodeId}::${handleId}`;
}
function incrementUsage(map: Map<string, number>, nodeId: string, handleId: string): void {
const key = usageKey(nodeId, handleId);
map.set(key, (map.get(key) ?? 0) + 1);
}
function decrementUsage(map: Map<string, number>, nodeId: string, handleId: string): void {
const key = usageKey(nodeId, handleId);
map.set(key, Math.max(0, (map.get(key) ?? 0) - 1));
}
function getUsage(map: Map<string, number>, nodeId: string, handleId: string): number {
return map.get(usageKey(nodeId, handleId)) ?? 0;
}
function pickHandleByUsage(
candidates: string[],
nodeId: string,
usageMap: Map<string, number>,
): string {
const free = candidates.filter((handleId) => getUsage(usageMap, nodeId, handleId) === 0);
if (free.length > 0) {
return free[0];
}
let bestHandle = candidates[0];
let bestCount = Number.POSITIVE_INFINITY;
for (const handleId of candidates) {
const count = getUsage(usageMap, nodeId, handleId);
if (count < bestCount) {
bestHandle = handleId;
bestCount = count;
}
}
return bestHandle;
}
function applyEdgeWithHandles(
edge: Edge,
sourceHandle: string,
targetHandle: string,
sourceUsage: Map<string, number>,
targetUsage: Map<string, number>,
): Edge {
incrementUsage(sourceUsage, edge.source, sourceHandle);
incrementUsage(targetUsage, edge.target, targetHandle);
return { ...edge, sourceHandle, targetHandle, type: "semantic" };
}
function getNodeCenter(node: RecipeNode): { x: number; y: number } {
const width = readNodeWidth(node) ?? DEFAULT_NODE_WIDTH;
const height = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT;
return {
x: node.position.x + width / 2,
y: node.position.y + height / 2,
};
}
function collectBounds(ids: string[], nodesById: Map<string, RecipeNode>): Bounds | null {
const rects = ids
.map((id) => nodesById.get(id))
.flatMap((node) => (node ? [toRect(node)] : []));
if (rects.length === 0) {
return null;
}
return rects.reduce<Bounds>(
(acc, rect) => ({
minX: Math.min(acc.minX, rect.x),
maxX: Math.max(acc.maxX, rect.x + rect.width),
minY: Math.min(acc.minY, rect.y),
maxY: Math.max(acc.maxY, rect.y + rect.height),
}),
{
minX: rects[0].x,
maxX: rects[0].x + rects[0].width,
minY: rects[0].y,
maxY: rects[0].y + rects[0].height,
},
);
}
function sortPreferredLlmTargetHandles(
direction: LayoutDirection,
sourceNode: RecipeNode | undefined,
targetNode: RecipeNode | undefined,
): string[] {
const sourceCenter = sourceNode ? getNodeCenter(sourceNode) : { x: 0, y: 0 };
const targetCenter = targetNode ? getNodeCenter(targetNode) : { x: 0, y: 0 };
if (direction === "TB") {
const horizontalFirst =
sourceCenter.x <= targetCenter.x
? [HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight]
: [HANDLE_IDS.dataInRight, HANDLE_IDS.dataIn];
return [...horizontalFirst, HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom];
}
const verticalFirst =
sourceCenter.y <= targetCenter.y
? [HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom]
: [HANDLE_IDS.dataInBottom, HANDLE_IDS.dataInTop];
return [...verticalFirst, HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight];
}
function getProviderSourceHandleCandidates(direction: LayoutDirection): string[] {
return direction === "TB"
? [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom]
: [HANDLE_IDS.semanticOutBottom, HANDLE_IDS.semanticOut];
}
function getProviderTargetHandleCandidates(direction: LayoutDirection): string[] {
return direction === "TB"
? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
: [HANDLE_IDS.semanticInTop, HANDLE_IDS.semanticIn];
}
function getConfigSourceHandleCandidates(direction: LayoutDirection): string[] {
return direction === "TB" ? [HANDLE_IDS.semanticOut] : [HANDLE_IDS.semanticOutBottom];
}
export function optimizeModelInfraEdgeHandles(
edges: Edge[],
nodes: RecipeNode[],
configs: Record<string, NodeConfig>,
direction: LayoutDirection,
): Edge[] {
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
const sourceUsage = new Map<string, number>();
const targetUsage = new Map<string, number>();
for (const edge of edges) {
const sourceHandle = normalizeRecipeHandleId(edge.sourceHandle);
const targetHandle = normalizeRecipeHandleId(edge.targetHandle);
if (sourceHandle) {
incrementUsage(sourceUsage, edge.source, sourceHandle);
}
if (targetHandle) {
incrementUsage(targetUsage, edge.target, targetHandle);
}
}
const nextEdges: Edge[] = [];
for (const edge of edges) {
const source = configs[edge.source];
const target = configs[edge.target];
if (!(source && target)) {
nextEdges.push(edge);
continue;
}
const sourceHandleBefore = normalizeRecipeHandleId(edge.sourceHandle);
const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle);
const isModelSemantic =
isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs);
if (!isModelSemantic) {
nextEdges.push(edge);
continue;
}
if (sourceHandleBefore) {
decrementUsage(sourceUsage, edge.source, sourceHandleBefore);
}
if (targetHandleBefore) {
decrementUsage(targetUsage, edge.target, targetHandleBefore);
}
if (isProviderToConfigEdge(edge, configs)) {
const sourceCandidates = getProviderSourceHandleCandidates(direction);
const targetCandidates = getProviderTargetHandleCandidates(direction);
const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
nextEdges.push(
applyEdgeWithHandles(
edge,
sourceHandle,
targetHandle,
sourceUsage,
targetUsage,
),
);
continue;
}
const sourceCandidates = getConfigSourceHandleCandidates(direction);
const targetCandidates = sortPreferredLlmTargetHandles(
direction,
nodesById.get(edge.source),
nodesById.get(edge.target),
);
const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
nextEdges.push(
applyEdgeWithHandles(
edge,
sourceHandle,
targetHandle,
sourceUsage,
targetUsage,
),
);
}
return nextEdges;
}
export function centerModelInfraNodes(
nodes: RecipeNode[],
edges: Edge[],
configs: Record<string, NodeConfig>,
direction: LayoutDirection,
): RecipeNode[] {
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
const configToLlmIds = new Map<string, string[]>();
const providerToConfigIds = new Map<string, string[]>();
for (const edge of edges) {
if (isProviderToConfigEdge(edge, configs)) {
const entries = providerToConfigIds.get(edge.source) ?? [];
if (!entries.includes(edge.target)) {
entries.push(edge.target);
}
providerToConfigIds.set(edge.source, entries);
continue;
}
if (isConfigToLlmEdge(edge, configs)) {
const entries = configToLlmIds.get(edge.source) ?? [];
if (!entries.includes(edge.target)) {
entries.push(edge.target);
}
configToLlmIds.set(edge.source, entries);
}
}
const modelConfigIds = Object.values(configs)
.filter((config) => config.kind === "model_config" && nodesById.has(config.id))
.map((config) => config.id);
const modelProviderIds = Object.values(configs)
.filter((config) => config.kind === "model_provider" && nodesById.has(config.id))
.map((config) => config.id);
const occupiedById = new Map(nodes.map((node) => [node.id, toRect(node)] as const));
const clusterGap = 72;
const placeNode = (nodeId: string, preferred: XYPosition): void => {
const currentNode = nodesById.get(nodeId);
if (!currentNode) {
return;
}
const width = readNodeWidth(currentNode) ?? DEFAULT_NODE_WIDTH;
const height = readNodeHeight(currentNode) ?? DEFAULT_NODE_HEIGHT;
occupiedById.delete(nodeId);
const position = findNonOverlappingPosition(
preferred,
width,
height,
Array.from(occupiedById.values()),
);
const nextNode = { ...currentNode, position };
nodesById.set(nodeId, nextNode);
occupiedById.set(nodeId, {
x: position.x,
y: position.y,
width,
height,
});
};
for (const modelConfigId of modelConfigIds) {
const llmIds = configToLlmIds.get(modelConfigId) ?? [];
const targetBounds = collectBounds(llmIds, nodesById);
const modelConfigNode = nodesById.get(modelConfigId);
if (!(targetBounds && modelConfigNode)) {
continue;
}
const width = readNodeWidth(modelConfigNode) ?? DEFAULT_NODE_WIDTH;
const height = readNodeHeight(modelConfigNode) ?? DEFAULT_NODE_HEIGHT;
const preferred =
direction === "LR"
? {
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
y: targetBounds.minY - height - clusterGap,
}
: {
x: targetBounds.minX - width - clusterGap,
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
};
placeNode(modelConfigId, preferred);
}
for (const modelProviderId of modelProviderIds) {
const configIds = providerToConfigIds.get(modelProviderId) ?? [];
const targetBounds = collectBounds(configIds, nodesById);
const modelProviderNode = nodesById.get(modelProviderId);
if (!(targetBounds && modelProviderNode)) {
continue;
}
const width = readNodeWidth(modelProviderNode) ?? DEFAULT_NODE_WIDTH;
const height = readNodeHeight(modelProviderNode) ?? DEFAULT_NODE_HEIGHT;
const preferred =
direction === "LR"
? {
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
y: targetBounds.minY - height - clusterGap,
}
: {
x: targetBounds.minX - width - clusterGap,
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
};
placeNode(modelProviderId, preferred);
}
return nodes.map((node) => nodesById.get(node.id) ?? node);
}

View file

@ -26,9 +26,17 @@ import {
} from "../blocks/registry";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
import { HANDLE_IDS, remapRecipeEdgeHandlesForLayout } from "../utils/handles";
import {
HANDLE_IDS,
normalizeRecipeHandleId,
remapRecipeEdgeHandlesForLayout,
} from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
import {
centerModelInfraNodes,
optimizeModelInfraEdgeHandles,
} from "./helpers/model-infra-layout";
import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals";
import {
applyRenameToConfigs,
@ -206,6 +214,17 @@ function connectSemantic(
};
}
function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
const source = configs[edge.source];
const target = configs[edge.target];
return Boolean(
source &&
target &&
((source.kind === "model_provider" && target.kind === "model_config") ||
(source.kind === "model_config" && target.kind === "llm")),
);
}
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
...INITIAL_STATE,
setSheetView: (view) => set({ sheetView: view }),
@ -217,10 +236,19 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
edges: state.edges.map((edge) => ({
...edge,
...remapRecipeEdgeHandlesForLayout(edge, direction),
})),
edges: state.edges.map((edge) => {
if (isModelSemanticEdge(edge, state.configs)) {
return {
...edge,
sourceHandle: normalizeRecipeHandleId(edge.sourceHandle),
targetHandle: normalizeRecipeHandleId(edge.targetHandle),
};
}
return {
...edge,
...remapRecipeEdgeHandlesForLayout(edge, direction),
};
}),
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@ -253,10 +281,23 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
}
return { ...node, position };
});
const centeredNodes = centerModelInfraNodes(
nextNodes,
state.edges,
state.configs,
state.layoutDirection,
);
const optimizedEdges = optimizeModelInfraEdgeHandles(
state.edges,
centeredNodes,
state.configs,
state.layoutDirection,
);
return {
auxNodePositions: {},
edges: optimizedEdges,
nodes: applyLayoutDirectionToNodes(
nextNodes,
centeredNodes,
state.configs,
state.layoutDirection,
),

View file

@ -1,10 +1,12 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../../types";
import {
HANDLE_IDS,
isDataSourceHandle,
isDataTargetHandle,
isSemanticSourceHandle,
isSemanticTargetHandle,
normalizeRecipeHandleId,
} from "../handles";
import { isSemanticRelation } from "./relations";
import {
@ -127,6 +129,97 @@ function isCompetingIncomingEdge(
return source.kind === "sampler" && source.sampler_type === "datetime";
}
function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
return (
(source.kind === "model_provider" && target.kind === "model_config") ||
(source.kind === "model_config" && target.kind === "llm")
);
}
function countHandleUsage(
edges: Edge[],
nodeId: string,
handleId: string,
lane: "source" | "target",
): number {
return edges.reduce((count, edge) => {
const edgeNodeId = lane === "source" ? edge.source : edge.target;
if (edgeNodeId !== nodeId) {
return count;
}
const edgeHandleId =
lane === "source"
? normalizeRecipeHandleId(edge.sourceHandle)
: normalizeRecipeHandleId(edge.targetHandle);
return edgeHandleId === handleId ? count + 1 : count;
}, 0);
}
function pickLeastUsedHandle(
candidates: string[],
requested: string | null,
usageFor: (handleId: string) => number,
): string {
let bestHandle = candidates[0];
let bestCount = Number.POSITIVE_INFINITY;
const requestedNormalized = requested
? normalizeRecipeHandleId(requested)
: null;
for (const candidate of candidates) {
const usage = usageFor(candidate);
if (usage < bestCount) {
bestHandle = candidate;
bestCount = usage;
continue;
}
if (usage === bestCount && requestedNormalized === candidate) {
bestHandle = candidate;
}
}
return bestHandle;
}
function chooseModelSemanticHandles(
connection: Connection,
source: NodeConfig,
target: NodeConfig,
edges: Edge[],
): Connection {
if (!isModelSemanticRelation(source, target)) {
return connection;
}
const sourceCandidates = [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom];
const targetCandidates =
target.kind === "model_config"
? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
: [
HANDLE_IDS.dataIn,
HANDLE_IDS.dataInTop,
HANDLE_IDS.dataInRight,
HANDLE_IDS.dataInBottom,
];
const sourceHandle = pickLeastUsedHandle(
sourceCandidates,
connection.sourceHandle ?? null,
(handleId) => countHandleUsage(edges, source.id, handleId, "source"),
);
const targetHandle = pickLeastUsedHandle(
targetCandidates,
connection.targetHandle ?? null,
(handleId) => countHandleUsage(edges, target.id, handleId, "target"),
);
return {
...connection,
sourceHandle,
targetHandle,
};
}
export function isValidRecipeConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
@ -177,8 +270,14 @@ export function applyRecipeConnection(
!isCompetingIncomingEdge(edge, target.id, singleRefRelation, configs),
)
: edges;
const resolvedConnection = chooseModelSemanticHandles(
connection,
source,
target,
nextBaseEdges,
);
const nextEdges = addEdge(
{ ...connection, type: semanticRelation ? "semantic" : "canvas" },
{ ...resolvedConnection, type: semanticRelation ? "semantic" : "canvas" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {

View file

@ -1,6 +1,8 @@
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../constants";
import type { LayoutDirection } from "../types";
import { readNodeHeight, readNodeWidth } from "./rf-node-dimensions";
type LayoutOptions = {
direction?: LayoutDirection;
@ -21,8 +23,8 @@ export function getLayoutedElements<TNode extends Node>(
nodesep = 80,
ranksep = 80,
edgesep = 28,
nodeWidth = 220,
nodeHeight = 64,
nodeWidth = DEFAULT_NODE_WIDTH,
nodeHeight = DEFAULT_NODE_HEIGHT,
} = options;
const graph = new dagre.graphlib.Graph();
@ -36,8 +38,8 @@ export function getLayoutedElements<TNode extends Node>(
});
nodes.forEach((node) => {
const width = node.measured?.width ?? nodeWidth;
const height = node.measured?.height ?? nodeHeight;
const width = readNodeWidth(node) ?? nodeWidth;
const height = readNodeHeight(node) ?? nodeHeight;
graph.setNode(node.id, { width, height });
});
@ -54,8 +56,8 @@ export function getLayoutedElements<TNode extends Node>(
const layoutedNodes = nodes.map((node) => {
const pos = graph.node(node.id);
const width = node.measured?.width ?? nodeWidth;
const height = node.measured?.height ?? nodeHeight;
const width = readNodeWidth(node) ?? nodeWidth;
const height = readNodeHeight(node) ?? nodeHeight;
return {
...node,
position: {