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 c7f14ebbc4..a08b538afc 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 @@ -4,19 +4,15 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Handle, - NodeResizer, + Position, type Node, type NodeProps, useUpdateNodeInternals, } from "@xyflow/react"; 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 type { LlmConfig, Score, ScoreOption } from "../types"; +import { AUX_HANDLE_CLASS } 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"; @@ -28,14 +24,12 @@ type PromptInputNodeData = { llmId: string; field: PromptField; title: string; - layoutDirection: LayoutDirection; }; type JudgeScoreNodeData = { kind: "llm-judge-score"; llmId: string; scoreIndex: number; - layoutDirection: LayoutDirection; }; export type RecipeGraphAuxNodeData = PromptInputNodeData | JudgeScoreNodeData; @@ -104,24 +98,47 @@ function AuxNodeBase({ return null; } - const sourcePosition = getAuxSourceHandlePosition(data.layoutDirection); + const sourceHandles = ( + <> + + + + + + ); if (data.kind === "llm-prompt-input") { const value = data.field === "prompt" ? config.prompt : config.system_prompt; return ( - {data.title} @@ -137,14 +154,7 @@ function AuxNodeBase({ /> - + {sourceHandles} ); } @@ -190,18 +200,6 @@ function AuxNodeBase({ return ( - {score.name.trim() || `Scorer ${data.scoreIndex + 1}`} @@ -260,14 +258,7 @@ function AuxNodeBase({ - + {sourceHandles} ); } 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 54bef62fc6..e5c8a5d3f0 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 @@ -20,7 +20,6 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { - Handle, NodeResizer, Position, useUpdateNodeInternals, @@ -32,12 +31,11 @@ import { useRecipeStudioStore } from "../stores/recipe-studio"; import type { RecipeNode as RecipeGraphNodeType, LlmType, - LayoutDirection, NodeConfig, SamplerType, } from "../types"; import { NODE_HANDLE_CLASS } from "../utils/handle-layout"; -import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles"; +import { HANDLE_IDS } from "../utils/handles"; import { InlineCategoryBadges } from "./inline/inline-category-badges"; import { InlineExpression } from "./inline/inline-expression"; import { InlineLlm } from "./inline/inline-llm"; @@ -268,89 +266,6 @@ function renderNodeBody( return

{summary}

; } -type LlmInputHandleItem = { - id: string; - label: string; -}; - -function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleItem[] { - if (!(config && config.kind === "llm")) { - return []; - } - const items: LlmInputHandleItem[] = []; - if (config.system_prompt.trim()) { - items.push({ id: HANDLE_IDS.llmSystemIn, label: "System" }); - } - if (config.prompt.trim()) { - items.push({ id: HANDLE_IDS.llmPromptIn, label: "Prompt" }); - } - if (config.llm_type === "judge") { - (config.scores ?? []).forEach((score, index) => { - items.push({ - id: getLlmJudgeScoreHandleId(index), - label: score.name.trim() || `Score ${index + 1}`, - }); - }); - } - return items; -} - -type LlmInputHandlesProps = { - items: LlmInputHandleItem[]; - layoutDirection: LayoutDirection; -}; - -function LlmInputHandles({ - items, - layoutDirection, -}: LlmInputHandlesProps): ReactElement | null { - if (items.length === 0) { - return null; - } - const isTopBottom = layoutDirection === "TB"; - - if (isTopBottom) { - return ( -
- {items.map((item) => ( -
- - {item.label} -
- ))} -
- ); - } - - return ( -
- {items.map((item) => ( -
- - - {item.label} - -
- ))} -
- ); -} - function RecipeGraphNodeBase({ id, data, @@ -418,7 +333,6 @@ function RecipeGraphNodeBase({ data.kind === "model_config" || data.kind === "model_provider"; const summary = getConfigSummary(config); const nodeBody = renderNodeBody(config, summary, updateConfig); - const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : []; const canShowLlmAux = config?.kind === "llm" && (Boolean(config.prompt.trim()) || @@ -491,7 +405,6 @@ function RecipeGraphNodeBase({ - {nodeBody} @@ -506,6 +419,15 @@ function RecipeGraphNodeBase({ labelClassName="sr-only" handleClassName={NODE_HANDLE_CLASS} /> + + + + )} 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 ca4af3a23c..c83bd1fb28 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -94,7 +94,6 @@ export function RecipeStudioPage({ nodes, edges, auxNodePositions, - auxNodeSizes, llmAuxVisibility, configs, processors, @@ -125,15 +124,11 @@ export function RecipeStudioPage({ setLayoutDirection, applyLayout, setAuxNodePosition, - setAuxNodeSize, - syncAuxNodePositions, - syncAuxNodeSizes, } = useRecipeStudioStore( useShallow((state) => ({ nodes: state.nodes, edges: state.edges, auxNodePositions: state.auxNodePositions, - auxNodeSizes: state.auxNodeSizes, llmAuxVisibility: state.llmAuxVisibility, configs: state.configs, processors: state.processors, @@ -164,9 +159,6 @@ export function RecipeStudioPage({ setLayoutDirection: state.setLayoutDirection, applyLayout: state.applyLayout, setAuxNodePosition: state.setAuxNodePosition, - setAuxNodeSize: state.setAuxNodeSize, - syncAuxNodePositions: state.syncAuxNodePositions, - syncAuxNodeSizes: state.syncAuxNodeSizes, })), ); const [sheetContainer, setSheetContainer] = useState( @@ -202,12 +194,10 @@ export function RecipeStudioPage({ configs, layoutDirection, auxNodePositions, - auxNodeSizes, llmAuxVisibility, }); }, [ auxNodePositions, - auxNodeSizes, configs, edges, layoutDirection, @@ -218,12 +208,6 @@ export function RecipeStudioPage({ () => displayGraph.nodes.map((node) => node.id), [displayGraph.nodes], ); - useEffect(() => { - syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults); - }, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]); - useEffect(() => { - syncAuxNodeSizes(displayGraph.auxNodeIds); - }, [displayGraph.auxNodeIds, syncAuxNodeSizes]); const handleNodeClick = useCallback( (_: unknown, node: Node) => { @@ -250,7 +234,7 @@ export function RecipeStudioPage({ const handleNodesChange = useCallback( (changes: NodeChange>[]) => { - applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize }); + applyAuxNodeChanges(changes, { setAuxNodePosition }); const next = filterNodeChangesByIds( changes as NodeChange[], baseNodeIds, @@ -259,7 +243,7 @@ export function RecipeStudioPage({ onNodesChange(next); } }, - [baseNodeIds, onNodesChange, setAuxNodePosition, setAuxNodeSize], + [baseNodeIds, onNodesChange, setAuxNodePosition], ); const handleEdgesChange = useCallback( @@ -288,8 +272,16 @@ export function RecipeStudioPage({ }, []); const payloadResult = useMemo( - () => buildRecipePayload(configs, nodes, edges, processors, layoutDirection), - [configs, edges, layoutDirection, nodes, processors], + () => + buildRecipePayload( + configs, + nodes, + edges, + processors, + layoutDirection, + auxNodePositions, + ), + [auxNodePositions, configs, edges, layoutDirection, nodes, processors], ); const getCurrentPayloadFromStore = useCallback((): RecipePayload => { const state = useRecipeStudioStore.getState(); @@ -299,6 +291,7 @@ export function RecipeStudioPage({ state.edges, state.processors, state.layoutDirection, + state.auxNodePositions, ).payload; }, []); const { 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 deleted file mode 100644 index 73cd04b4cc..0000000000 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { XYPosition } from "@xyflow/react"; - -export function syncPositionsRecord( - prev: Record, - activeIds: string[], - defaults: Record, -): Record { - const next: Record = {}; - for (const id of activeIds) { - const existing = prev[id]; - if (existing) { - next[id] = existing; - continue; - } - const fallback = defaults[id]; - if (fallback) { - next[id] = fallback; - } - } - - const prevIds = Object.keys(prev); - const nextIds = Object.keys(next); - if (prevIds.length !== nextIds.length) { - return next; - } - for (const id of nextIds) { - const a = prev[id]; - const b = next[id]; - if (!(a && b && a.x === b.x && a.y === b.y)) { - return next; - } - } - return prev; -} - -export function syncSizesRecord( - prev: Record, - activeIds: string[], -): Record { - const active = new Set(activeIds); - const next: Record = {}; - for (const [id, size] of Object.entries(prev)) { - if (active.has(id)) { - next[id] = size; - } - } - - const prevIds = Object.keys(prev); - const nextIds = Object.keys(next); - if (prevIds.length !== nextIds.length) { - return next; - } - for (const id of nextIds) { - const a = prev[id]; - const b = next[id]; - if (!(a && b && a.width === b.width && a.height === b.height)) { - return next; - } - } - 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 038705cc27..4eee51ea62 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -29,7 +29,6 @@ import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph"; import { HANDLE_IDS, remapRecipeEdgeHandlesForLayout } from "../utils/handles"; import type { RecipeSnapshot } from "../utils/import"; import { getLayoutedElements } from "../utils/layout"; -import { syncPositionsRecord, syncSizesRecord } from "./helpers/aux-sync"; import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals"; import { applyRenameToConfigs, @@ -53,7 +52,6 @@ type RecipeStudioState = { nodes: RecipeNode[]; edges: Edge[]; auxNodePositions: Record; - auxNodeSizes: Record; llmAuxVisibility: Record; configs: Record; processors: RecipeProcessorConfig[]; @@ -83,15 +81,6 @@ type RecipeStudioState = { updateConfig: (id: string, patch: Partial) => void; loadRecipe: (snapshot: RecipeSnapshot) => void; setAuxNodePosition: (id: string, position: XYPosition) => void; - setAuxNodeSize: ( - id: string, - size: { width: number; height: number }, - ) => void; - syncAuxNodePositions: ( - activeIds: string[], - defaults: Record, - ) => void; - syncAuxNodeSizes: (activeIds: string[]) => void; onNodesChange: (changes: NodeChange[]) => void; onEdgesChange: (changes: EdgeChange[]) => void; onConnect: (connection: Connection) => void; @@ -102,7 +91,6 @@ const INITIAL_STATE = { nodes: [], edges: [], auxNodePositions: {}, - auxNodeSizes: {}, llmAuxVisibility: {}, configs: {}, processors: [], @@ -118,7 +106,6 @@ const INITIAL_STATE = { | "nodes" | "edges" | "auxNodePositions" - | "auxNodeSizes" | "llmAuxVisibility" | "configs" | "processors" @@ -248,8 +235,7 @@ export const useRecipeStudioStore = create((set, get) => ({ edges: state.edges, configs: state.configs, layoutDirection: state.layoutDirection, - auxNodePositions: state.auxNodePositions, - auxNodeSizes: state.auxNodeSizes, + auxNodePositions: {}, llmAuxVisibility: state.llmAuxVisibility, }); const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, { @@ -267,25 +253,8 @@ export const useRecipeStudioStore = create((set, get) => ({ } 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: nextAuxNodePositions, + auxNodePositions: {}, nodes: applyLayoutDirectionToNodes( nextNodes, state.configs, @@ -461,8 +430,7 @@ export const useRecipeStudioStore = create((set, get) => ({ layoutDirection: snapshot.layoutDirection, nextId: snapshot.nextId, nextY: snapshot.nextY, - auxNodePositions: {}, - auxNodeSizes: {}, + auxNodePositions: snapshot.auxNodePositions ?? {}, llmAuxVisibility: {}, activeConfigId: null, dialogOpen: false, @@ -482,36 +450,6 @@ export const useRecipeStudioStore = create((set, get) => ({ }, }; }), - setAuxNodeSize: (id, size) => - set((state) => { - const width = Math.max(1, size.width); - const height = Math.max(1, size.height); - const current = state.auxNodeSizes[id]; - if (current && current.width === width && current.height === height) { - return state; - } - return { - auxNodeSizes: { - ...state.auxNodeSizes, - [id]: { width, height }, - }, - }; - }), - syncAuxNodePositions: (activeIds, defaults) => - set((state) => { - const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults); - if (next === state.auxNodePositions) { - return state; - } - return { - auxNodePositions: next, - }; - }), - syncAuxNodeSizes: (activeIds) => - set((state) => { - const next = syncSizesRecord(state.auxNodeSizes, activeIds); - return next === state.auxNodeSizes ? state : { auxNodeSizes: next }; - }), updateConfig: (id, patch) => { const applyUpdate = (state: RecipeStudioState) => { const current = state.configs[id]; 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 424325fc96..ef7cb62661 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 @@ -7,7 +7,6 @@ import { getDefaultDataTargetHandle, getDefaultSemanticSourceHandle, getDefaultSemanticTargetHandle, - getLlmJudgeScoreHandleId, HANDLE_IDS, isDataSourceHandle, isDataTargetHandle, @@ -24,15 +23,12 @@ type DisplayGraphInput = { configs: Record; layoutDirection: LayoutDirection; auxNodePositions: Record; - auxNodeSizes: Record; llmAuxVisibility: Record; }; export type DisplayGraph = { nodes: Array>; edges: Edge[]; - auxNodeIds: string[]; - auxDefaults: Record; }; function normalizeEdge( @@ -99,7 +95,6 @@ function normalizeEdge( type AuxNodeItem = { key: string; - targetHandle: string; data: RecipeGraphAuxNodeData; }; @@ -136,42 +131,235 @@ 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; + const step = 24; + for (let ring = 0; ring <= 10; 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 = toRect(candidate, width, height); + if (!occupied.some((other) => intersects(rect, other))) { + return candidate; + } } } } - return preferred; } +type HandleSide = "left" | "right" | "top" | "bottom"; + +const SIDE_TO_TARGET_HANDLE: Record = { + left: HANDLE_IDS.dataIn, + right: HANDLE_IDS.dataInRight, + top: HANDLE_IDS.dataInTop, + bottom: HANDLE_IDS.dataInBottom, +}; + +function getTargetSide( + handleId: string | null | undefined, + direction: LayoutDirection, +): HandleSide { + const normalized = normalizeRecipeHandleId(handleId); + if (!normalized) { + return direction === "TB" ? "top" : "left"; + } + if ( + normalized === HANDLE_IDS.dataInRight || + normalized === HANDLE_IDS.semanticInRight + ) { + return "right"; + } + if ( + normalized === HANDLE_IDS.dataInBottom || + normalized === HANDLE_IDS.semanticInBottom + ) { + return "bottom"; + } + if ( + normalized === HANDLE_IDS.dataInTop || + normalized === HANDLE_IDS.semanticInTop + ) { + return "top"; + } + return "left"; +} + +function getSourceSide( + handleId: string | null | undefined, + direction: LayoutDirection, +): HandleSide { + const normalized = normalizeRecipeHandleId(handleId); + if (!normalized) { + return direction === "TB" ? "bottom" : "right"; + } + if ( + normalized === HANDLE_IDS.dataOutLeft || + normalized === HANDLE_IDS.semanticOutLeft + ) { + return "left"; + } + if ( + normalized === HANDLE_IDS.dataOutTop || + normalized === HANDLE_IDS.semanticOutTop + ) { + return "top"; + } + if ( + normalized === HANDLE_IDS.dataOutBottom || + normalized === HANDLE_IDS.semanticOutBottom + ) { + return "bottom"; + } + return "right"; +} + +function pickAuxTargetHandle( + llmId: string, + direction: LayoutDirection, + edges: Edge[], +): string { + const occupied = new Set(); + for (const edge of edges) { + if (edge.source.startsWith("aux-") || edge.target.startsWith("aux-")) { + continue; + } + if (edge.target === llmId) { + occupied.add(getTargetSide(edge.targetHandle, direction)); + } + if (edge.source === llmId) { + occupied.add(getSourceSide(edge.sourceHandle, direction)); + } + } + + const priority: HandleSide[] = + direction === "LR" + ? ["left", "right", "bottom", "top"] + : ["top", "bottom", "right", "left"]; + for (const side of priority) { + if (!occupied.has(side)) { + return SIDE_TO_TARGET_HANDLE[side]; + } + } + + const fallback: HandleSide = direction === "LR" ? "bottom" : "right"; + return SIDE_TO_TARGET_HANDLE[fallback]; +} + +function getHandleSideFromTargetHandle(targetHandle: string): HandleSide { + if (targetHandle === HANDLE_IDS.dataInRight) { + return "right"; + } + if (targetHandle === HANDLE_IDS.dataInTop) { + return "top"; + } + if (targetHandle === HANDLE_IDS.dataInBottom) { + return "bottom"; + } + return "left"; +} + +function pickAuxSourceHandle( + auxPosition: XYPosition, + auxWidth: number, + auxHeight: number, + llmPosition: XYPosition, + llmWidth: number, + llmHeight: number, +): string { + const auxCenter = { + x: auxPosition.x + auxWidth / 2, + y: auxPosition.y + auxHeight / 2, + }; + const llmCenter = { + x: llmPosition.x + llmWidth / 2, + y: llmPosition.y + llmHeight / 2, + }; + const dx = llmCenter.x - auxCenter.x; + const dy = llmCenter.y - auxCenter.y; + + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? HANDLE_IDS.llmInputOutRight : HANDLE_IDS.llmInputOutLeft; + } + return dy >= 0 ? HANDLE_IDS.llmInputOutBottom : HANDLE_IDS.llmInputOutTop; +} + +type AppendAuxNodeAndEdgeInput = { + auxNodes: Node[]; + auxEdges: Edge[]; + entry: { + item: AuxNodeItem; + auxId: string; + width: number; + height: number; + }; + position: XYPosition; + parentNode: Node; + parentWidth: number; + parentHeight: number; + auxTargetHandle: string; +}; + +function appendAuxNodeAndEdge({ + auxNodes, + auxEdges, + entry, + position, + parentNode, + parentWidth, + parentHeight, + auxTargetHandle, +}: AppendAuxNodeAndEdgeInput): void { + auxNodes.push({ + id: entry.auxId, + type: "aux", + data: entry.item.data, + position, + width: entry.width, + height: entry.height, + style: { + width: entry.width, + height: entry.height, + }, + draggable: true, + selectable: true, + focusable: true, + connectable: false, + }); + + auxEdges.push({ + id: `e-${entry.auxId}-${parentNode.id}`, + source: entry.auxId, + sourceHandle: pickAuxSourceHandle( + position, + entry.width, + entry.height, + parentNode.position, + parentWidth, + parentHeight, + ), + target: parentNode.id, + targetHandle: auxTargetHandle, + type: "canvas", + data: { path: "auto" }, + selectable: false, + focusable: false, + }); +} + export function deriveDisplayGraph({ nodes, edges, configs, layoutDirection, auxNodePositions, - auxNodeSizes, llmAuxVisibility, }: DisplayGraphInput): DisplayGraph { const displayNodes = nodes.map((node) => { @@ -190,8 +378,6 @@ export function deriveDisplayGraph({ }); const auxNodes: Node[] = []; const auxEdges: Edge[] = []; - const auxDefaults: Record = {}; - const auxNodeIds: string[] = []; const occupiedRects: Rect[] = displayNodes.map((node) => toRect( node.position, @@ -209,18 +395,18 @@ export function deriveDisplayGraph({ continue; } const llmDirection = node.data.layoutDirection ?? layoutDirection; + const auxTargetHandle = pickAuxTargetHandle(node.id, llmDirection, edges); + const auxTargetSide = getHandleSideFromTargetHandle(auxTargetHandle); const items: AuxNodeItem[] = []; if (config.system_prompt.trim()) { items.push({ key: "system", - targetHandle: HANDLE_IDS.llmSystemIn, data: { kind: "llm-prompt-input", llmId: config.id, field: "system_prompt", title: "System Prompt", - layoutDirection: llmDirection, }, }); } @@ -228,13 +414,11 @@ export function deriveDisplayGraph({ if (config.prompt.trim()) { items.push({ key: "prompt", - targetHandle: HANDLE_IDS.llmPromptIn, data: { kind: "llm-prompt-input", llmId: config.id, field: "prompt", title: "Prompt", - layoutDirection: llmDirection, }, }); } @@ -243,12 +427,10 @@ export function deriveDisplayGraph({ (config.scores ?? []).forEach((_score, scoreIndex) => { items.push({ key: `score-${scoreIndex}`, - targetHandle: getLlmJudgeScoreHandleId(scoreIndex), data: { kind: "llm-judge-score", llmId: config.id, scoreIndex, - layoutDirection: llmDirection, }, }); }); @@ -262,19 +444,20 @@ export function deriveDisplayGraph({ const parentHeight = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT; const itemsWithLayout = items.map((item) => { const auxId = `aux-${node.id}-${item.key}`; - const savedSize = auxNodeSizes[auxId]; return { item, auxId, - width: savedSize?.width ?? DEFAULT_NODE_WIDTH, - height: savedSize?.height ?? DEFAULT_NODE_HEIGHT, + width: DEFAULT_NODE_WIDTH, + height: DEFAULT_NODE_HEIGHT, }; }); const gap = 24; const sideOffset = 48; + const stackHorizontal = + auxTargetSide === "top" || auxTargetSide === "bottom"; - if (llmDirection === "TB") { + if (stackHorizontal) { const totalWidth = itemsWithLayout.reduce((sum, entry) => sum + entry.width, 0) + (itemsWithLayout.length - 1) * gap; @@ -284,51 +467,30 @@ export function deriveDisplayGraph({ for (const entry of itemsWithLayout) { const preferredPosition = { x: xCursor, - y: node.position.y - entry.height - sideOffset, + y: + auxTargetSide === "top" + ? node.position.y - entry.height - sideOffset + : node.position.y + parentHeight + sideOffset, }; const defaultPosition = findNonOverlappingPosition( preferredPosition, entry.width, entry.height, - llmDirection, occupiedRects, ); const position = auxNodePositions[entry.auxId] ?? defaultPosition; xCursor += entry.width + gap; - auxNodeIds.push(entry.auxId); - if (!auxNodePositions[entry.auxId]) { - auxDefaults[entry.auxId] = defaultPosition; - } occupiedRects.push(toRect(position, entry.width, entry.height)); - - auxNodes.push({ - id: entry.auxId, - type: "aux", - data: entry.item.data, + appendAuxNodeAndEdge({ + auxNodes, + auxEdges, + entry, position, - width: entry.width, - height: entry.height, - style: { - width: entry.width, - height: entry.height, - }, - draggable: true, - selectable: true, - focusable: true, - connectable: true, - }); - - auxEdges.push({ - id: `e-${entry.auxId}-${node.id}`, - source: entry.auxId, - sourceHandle: HANDLE_IDS.llmInputOut, - target: node.id, - targetHandle: entry.item.targetHandle, - type: "canvas", - data: { path: "auto" }, - selectable: false, - focusable: false, + parentNode: node, + parentWidth, + parentHeight, + auxTargetHandle, }); } continue; @@ -338,7 +500,10 @@ export function deriveDisplayGraph({ itemsWithLayout.reduce((sum, entry) => sum + entry.height, 0) + (itemsWithLayout.length - 1) * gap; const maxWidth = Math.max(...itemsWithLayout.map((entry) => entry.width)); - const baseX = node.position.x - maxWidth - sideOffset; + const baseX = + auxTargetSide === "right" + ? node.position.x + parentWidth + sideOffset + : node.position.x - maxWidth - sideOffset; let yCursor = node.position.y + (parentHeight - totalHeight) / 2; for (const entry of itemsWithLayout) { @@ -350,45 +515,21 @@ export function deriveDisplayGraph({ preferredPosition, entry.width, entry.height, - llmDirection, occupiedRects, ); const position = auxNodePositions[entry.auxId] ?? defaultPosition; yCursor += entry.height + gap; - auxNodeIds.push(entry.auxId); - if (!auxNodePositions[entry.auxId]) { - auxDefaults[entry.auxId] = defaultPosition; - } occupiedRects.push(toRect(position, entry.width, entry.height)); - - auxNodes.push({ - id: entry.auxId, - type: "aux", - data: entry.item.data, + appendAuxNodeAndEdge({ + auxNodes, + auxEdges, + entry, position, - width: entry.width, - height: entry.height, - style: { - width: entry.width, - height: entry.height, - }, - draggable: true, - selectable: true, - focusable: true, - connectable: true, - }); - - auxEdges.push({ - id: `e-${entry.auxId}-${node.id}`, - source: entry.auxId, - sourceHandle: HANDLE_IDS.llmInputOut, - target: node.id, - targetHandle: entry.item.targetHandle, - type: "canvas", - data: { path: "auto" }, - selectable: false, - focusable: false, + parentNode: node, + parentWidth, + parentHeight, + auxTargetHandle, }); } } @@ -398,7 +539,5 @@ export function deriveDisplayGraph({ edges: [...edges, ...auxEdges].map((edge) => normalizeEdge(edge, configs, layoutDirection), ), - auxNodeIds, - auxDefaults, }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts b/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts index 9f4af72ce9..467d62f5bf 100644 --- a/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts +++ b/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts @@ -1,36 +1,5 @@ -import { Position } from "@xyflow/react"; -import type { LayoutDirection } from "../types"; - export const NODE_HANDLE_CLASS = "pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"; export const AUX_HANDLE_CLASS = "!size-2 !border-border/80 !bg-muted/80 shadow-sm"; - -export type NodeHandleLayout = { - isTopBottom: boolean; - dataInPosition: Position; - dataOutPosition: Position; - semanticInPosition: Position; - semanticOutPosition: Position; -}; - -export function getNodeHandleLayout( - direction: LayoutDirection, -): NodeHandleLayout { - const isTopBottom = direction === "TB"; - return { - isTopBottom, - dataInPosition: isTopBottom ? Position.Top : Position.Left, - dataOutPosition: isTopBottom ? Position.Bottom : Position.Right, - semanticInPosition: isTopBottom ? Position.Left : Position.Top, - semanticOutPosition: isTopBottom ? Position.Right : Position.Bottom, - }; -} - -export function getAuxSourceHandlePosition( - direction: LayoutDirection, -): Position { - return direction === "TB" ? Position.Bottom : Position.Right; -} - diff --git a/studio/frontend/src/features/recipe-studio/utils/handles.ts b/studio/frontend/src/features/recipe-studio/utils/handles.ts index d7b154cc91..4e8b85e3ab 100644 --- a/studio/frontend/src/features/recipe-studio/utils/handles.ts +++ b/studio/frontend/src/features/recipe-studio/utils/handles.ts @@ -23,17 +23,14 @@ export const HANDLE_IDS = { semanticOutBottom: "semantic-out-bottom", semanticOutRight: "semantic-out-right", // llm prompt/scorer lanes - llmPromptIn: "llm-prompt-in", - llmSystemIn: "llm-system-in", - llmInputOut: "llm-input-out", + llmInputOutLeft: "llm-input-out-left", + llmInputOutRight: "llm-input-out-right", + llmInputOutTop: "llm-input-out-top", + llmInputOutBottom: "llm-input-out-bottom", } as const; export type RecipeHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS]; -export function getLlmJudgeScoreHandleId(index: number): string { - return `llm-judge-score-in-${index}`; -} - const LEGACY_HANDLE_ALIAS_MAP: Record = { [HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn, [HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut, diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts index e2ea6d754a..dcec8b071e 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts @@ -453,7 +453,7 @@ export function importRecipePayload(input: string): ImportResult { return { errors, snapshot: null }; } - const { layouts, edges: uiEdges, layoutDirection } = parseUi(ui); + const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui); const resolvedLayoutDirection = layoutDirection ?? "LR"; const nodes = buildNodes(configs, layouts); const edges = buildEdges( @@ -462,6 +462,15 @@ export function importRecipePayload(input: string): ImportResult { uiEdges, resolvedLayoutDirection, ); + const auxNodePositions = Object.fromEntries( + auxNodes.flatMap((item) => { + const llmId = nameToId.get(item.llm); + if (!llmId) { + return []; + } + return [[`aux-${llmId}-${item.key}`, { x: item.x, y: item.y }]]; + }), + ); const maxY = nodes.reduce( (acc, node) => Math.max(acc, node.position.y), @@ -474,6 +483,7 @@ export function importRecipePayload(input: string): ImportResult { configs: Object.fromEntries(configs.map((config) => [config.id, config])), nodes, edges, + auxNodePositions, processors, layoutDirection: resolvedLayoutDirection, nextId, diff --git a/studio/frontend/src/features/recipe-studio/utils/import/types.ts b/studio/frontend/src/features/recipe-studio/utils/import/types.ts index 9b5502a5ba..281a4e1ad5 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/types.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/types.ts @@ -1,4 +1,4 @@ -import type { Edge } from "@xyflow/react"; +import type { Edge, XYPosition } from "@xyflow/react"; import type { LayoutDirection, RecipeNode, @@ -10,6 +10,7 @@ export type RecipeSnapshot = { configs: Record; nodes: RecipeNode[]; edges: Edge[]; + auxNodePositions: Record; processors: RecipeProcessorConfig[]; layoutDirection: LayoutDirection; nextId: number; diff --git a/studio/frontend/src/features/recipe-studio/utils/import/ui.ts b/studio/frontend/src/features/recipe-studio/utils/import/ui.ts index 102fb4016e..76698fc8f2 100644 --- a/studio/frontend/src/features/recipe-studio/utils/import/ui.ts +++ b/studio/frontend/src/features/recipe-studio/utils/import/ui.ts @@ -7,14 +7,23 @@ import { isRecord, readString } from "./helpers"; type UiInput = { nodes?: unknown; edges?: unknown; + aux_nodes?: unknown; layout_direction?: unknown; layoutDirection?: unknown; }; +type ParsedAuxNode = { + llm: string; + key: string; + x: number; + y: number; +}; + export function parseUi( ui: UiInput | null, ): { layouts: Map; + auxNodes: ParsedAuxNode[]; edges: Array<{ from: string; to: string; @@ -25,6 +34,7 @@ export function parseUi( layoutDirection: "LR" | "TB" | null; } { const layouts = new Map(); + const auxNodes: ParsedAuxNode[] = []; const edges: Array<{ from: string; to: string; @@ -72,6 +82,21 @@ export function parseUi( } } } + if (ui && Array.isArray(ui.aux_nodes)) { + for (const node of ui.aux_nodes) { + if (!isRecord(node)) { + continue; + } + const llm = readString(node.llm); + const key = readString(node.key); + const x = typeof node.x === "number" ? node.x : null; + const y = typeof node.y === "number" ? node.y : null; + if (!(llm && key && x !== null && y !== null)) { + continue; + } + auxNodes.push({ llm, key, x, y }); + } + } const layoutDirectionRaw = readString(ui?.layout_direction) ?? readString(ui?.layoutDirection); const layoutDirection = @@ -81,7 +106,12 @@ export function parseUi( ? "LR" : null; - return { layouts, edges: edges.length > 0 ? edges : null, layoutDirection }; + return { + layouts, + auxNodes, + edges: edges.length > 0 ? edges : null, + layoutDirection, + }; } export function buildNodes( diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts index 606f6a3cd4..385e214b4c 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts @@ -1,4 +1,4 @@ -import type { Edge } from "@xyflow/react"; +import type { Edge, XYPosition } from "@xyflow/react"; import type { LayoutDirection, ModelConfig, @@ -70,6 +70,7 @@ export function buildRecipePayload( edges: Edge[], processors: RecipeProcessorConfig[] = [], layoutDirection: LayoutDirection = "LR", + auxNodePositions: Record = {}, ): RecipePayloadResult { const errors: string[] = []; const columns: Record[] = []; @@ -270,6 +271,27 @@ export function buildRecipePayload( }, ]; }); + const uiAuxNodes = Object.entries(auxNodePositions).flatMap( + ([auxId, position]) => { + const match = /^aux-([^-]+)-(.+)$/.exec(auxId); + if (!match) { + return []; + } + const [, llmId, key] = match; + const llmConfig = configs[llmId]; + if (!(llmConfig && llmConfig.kind === "llm")) { + return []; + } + return [ + { + llm: llmConfig.name, + key, + x: position.x, + y: position.y, + }, + ]; + }, + ); const recipeProcessors = buildProcessors(processors, errors); const seedConfig = firstSeed ? buildSeedConfig(firstSeed, errors) : undefined; const seedDropProcessor = firstSeed @@ -306,6 +328,7 @@ export function buildRecipePayload( nodes: uiNodes, edges: uiEdges, layout_direction: layoutDirection, + ...(uiAuxNodes.length > 0 && { aux_nodes: uiAuxNodes }), ...(firstSeed && { seed_source_type: firstSeed.seed_source_type }), ...(firstSeed && { seed_columns: firstSeed.seed_columns ?? [] }), ...(firstSeed && { diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts index e97f8db970..ceaec9c9b0 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts @@ -52,6 +52,13 @@ export type RecipePayload = { layout_direction?: "LR" | "TB"; // ui-only, used to preserve seed block mode across imports/refresh seed_source_type?: "hf" | "local" | "unstructured"; + // ui-only, persisted aux node positions by llm name + aux key + aux_nodes?: Array<{ + llm: string; + key: string; + x: number; + y: number; + }>; // ui-only, seed metadata cached for refresh/import UX seed_columns?: string[]; seed_drop_columns?: string[]; diff --git a/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts index bf6ebc397f..fa3fff8ddf 100644 --- a/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts +++ b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts @@ -5,43 +5,25 @@ import type { NodeChange, XYPosition, } from "@xyflow/react"; -import type { RecipeGraphAuxNodeData } from "../components/recipe-graph-aux-node"; -import type { RecipeNodeData } from "../types"; -type AnyNode = Node; - -export function applyAuxNodeChanges( - changes: NodeChange[], +export function applyAuxNodeChanges( + changes: NodeChange[], actions: { setAuxNodePosition: (id: string, position: XYPosition) => void; - setAuxNodeSize: ( - id: string, - size: { width: number; height: number }, - ) => void; }, ): void { for (const change of changes) { if (!("id" in change) || !change.id.startsWith("aux-")) { continue; } - if (change.type === "position") { - const nextPosition = change.position ?? change.positionAbsolute; - if (nextPosition) { - actions.setAuxNodePosition(change.id, nextPosition); - } + if (change.type !== "position") { continue; } - if ( - change.type === "dimensions" && - change.dimensions && - change.dimensions.width > 0 && - change.dimensions.height > 0 - ) { - actions.setAuxNodeSize(change.id, { - width: change.dimensions.width, - height: change.dimensions.height, - }); + const nextPosition = change.position ?? change.positionAbsolute; + if (!nextPosition) { + continue; } + actions.setAuxNodePosition(change.id, nextPosition); } } @@ -62,4 +44,3 @@ export function filterEdgeChangesByIds( (change): change is EdgeChange => "id" in change && ids.has(change.id), ); } -