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);