diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts new file mode 100644 index 0000000000..1def42bfbf --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts @@ -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): 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): 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, nodeId: string, handleId: string): void { + const key = usageKey(nodeId, handleId); + map.set(key, (map.get(key) ?? 0) + 1); +} + +function decrementUsage(map: Map, 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, nodeId: string, handleId: string): number { + return map.get(usageKey(nodeId, handleId)) ?? 0; +} + +function pickHandleByUsage( + candidates: string[], + nodeId: string, + usageMap: Map, +): 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, + targetUsage: Map, +): 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): Bounds | null { + const rects = ids + .map((id) => nodesById.get(id)) + .flatMap((node) => (node ? [toRect(node)] : [])); + if (rects.length === 0) { + return null; + } + return rects.reduce( + (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, + direction: LayoutDirection, +): Edge[] { + const nodesById = new Map(nodes.map((node) => [node.id, node] as const)); + const sourceUsage = new Map(); + const targetUsage = new Map(); + + 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, + direction: LayoutDirection, +): RecipeNode[] { + const nodesById = new Map(nodes.map((node) => [node.id, node] as const)); + const configToLlmIds = new Map(); + const providerToConfigIds = new Map(); + + 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); +} 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 4eee51ea62..43dd42dcfa 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -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): 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((set, get) => ({ ...INITIAL_STATE, setSheetView: (view) => set({ sheetView: view }), @@ -217,10 +236,19 @@ export const useRecipeStudioStore = create((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((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, ), diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts index 82450a766e..67ecf4e29d 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts @@ -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, @@ -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") { diff --git a/studio/frontend/src/features/recipe-studio/utils/layout.ts b/studio/frontend/src/features/recipe-studio/utils/layout.ts index da3ffb5cd8..519637172d 100644 --- a/studio/frontend/src/features/recipe-studio/utils/layout.ts +++ b/studio/frontend/src/features/recipe-studio/utils/layout.ts @@ -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( 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( }); 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( 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: {