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