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 836859969c..3b95aaba43 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
@@ -278,13 +278,13 @@ function LlmInputHandles({
key={item.id}
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
>
-
+
{item.label}
))}
@@ -340,8 +340,9 @@ function RecipeGraphNodeBase({
data.kind === "expression" ||
data.kind === "sampler" ||
data.kind === "seed";
- const showSemanticIn = data.kind === "llm" || data.kind === "model_config";
- const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider";
+ const showSemanticIn = data.kind === "model_config";
+ const showSemanticOut =
+ data.kind === "model_config" || data.kind === "model_provider";
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
@@ -468,16 +469,16 @@ function RecipeGraphNodeBase({
id={HANDLE_IDS.semanticIn}
title="Semantic input"
type="target"
- position={Position.Top}
+ position={Position.Left}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
buildRecipePayload(configs, nodes, edges, processors),
- [configs, edges, nodes, processors],
+ () => buildRecipePayload(configs, nodes, edges, processors, layoutDirection),
+ [configs, edges, layoutDirection, nodes, processors],
);
const getCurrentPayloadFromStore = useCallback((): RecipePayload => {
const state = useRecipeStudioStore.getState();
@@ -275,6 +275,7 @@ export function RecipeStudioPage({
state.nodes,
state.edges,
state.processors,
+ state.layoutDirection,
).payload;
}, []);
const {
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 4d854a82dc..e6d0f66b2c 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -26,7 +26,7 @@ import {
} from "../blocks/registry";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
-import { HANDLE_IDS } from "../utils/handles";
+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";
@@ -219,6 +219,10 @@ export const useRecipeStudioStore = create((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
+ edges: state.edges.map((edge) => ({
+ ...edge,
+ ...remapRecipeEdgeHandlesForLayout(edge, direction),
+ })),
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@@ -432,15 +436,16 @@ export const useRecipeStudioStore = create((set, get) => ({
addExpressionNode: () =>
set((state) => buildAddedNodeState(state, "expression", "expression")),
loadRecipe: (snapshot) =>
- set((state) => ({
+ set(() => ({
configs: snapshot.configs,
nodes: applyLayoutDirectionToNodes(
snapshot.nodes,
snapshot.configs,
- state.layoutDirection,
+ snapshot.layoutDirection,
),
edges: snapshot.edges,
processors: snapshot.processors,
+ layoutDirection: snapshot.layoutDirection,
nextId: snapshot.nextId,
nextY: snapshot.nextY,
auxNodePositions: {},
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 ab7b0202ed..424325fc96 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
@@ -2,7 +2,19 @@ import type { Edge, Node, XYPosition } from "@xyflow/react";
import type { RecipeGraphAuxNodeData } from "../../components/recipe-graph-aux-node";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
import type { RecipeNode, LayoutDirection, NodeConfig } from "../../types";
-import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../handles";
+import {
+ getDefaultDataSourceHandle,
+ getDefaultDataTargetHandle,
+ getDefaultSemanticSourceHandle,
+ getDefaultSemanticTargetHandle,
+ getLlmJudgeScoreHandleId,
+ HANDLE_IDS,
+ isDataSourceHandle,
+ isDataTargetHandle,
+ isSemanticSourceHandle,
+ isSemanticTargetHandle,
+ normalizeRecipeHandleId,
+} from "../handles";
import { readNodeHeight, readNodeWidth } from "../rf-node-dimensions";
import { isSemanticRelation } from "./relations";
@@ -23,7 +35,11 @@ export type DisplayGraph = {
auxDefaults: Record;
};
-function normalizeEdge(edge: Edge, configs: Record): Edge {
+function normalizeEdge(
+ edge: Edge,
+ configs: Record,
+ layoutDirection: LayoutDirection,
+): Edge {
const baseStyle = { stroke: "var(--foreground)", strokeWidth: 2 };
const isAux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-");
if (isAux) {
@@ -38,15 +54,45 @@ function normalizeEdge(edge: Edge, configs: Record): Edge {
const source = configs[edge.source];
const target = configs[edge.target];
const semantic = Boolean(source && target) && isSemanticRelation(source, target);
- const handles = semantic
- ? { sourceHandle: HANDLE_IDS.semanticOut, targetHandle: HANDLE_IDS.semanticIn }
- : { sourceHandle: HANDLE_IDS.dataOut, targetHandle: HANDLE_IDS.dataIn };
+ const sourceHandleNormalized = normalizeRecipeHandleId(edge.sourceHandle);
+ const targetHandleNormalized = normalizeRecipeHandleId(edge.targetHandle);
+ const semanticSourceDefault =
+ source?.kind === "llm"
+ ? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultSemanticSourceHandle(layoutDirection);
+ const semanticTargetDefault =
+ target?.kind === "llm"
+ ? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultSemanticTargetHandle(layoutDirection);
+ let sourceHandle = getDefaultDataSourceHandle(layoutDirection);
+ let targetHandle = getDefaultDataTargetHandle(layoutDirection);
+
+ if (semantic) {
+ sourceHandle =
+ isSemanticSourceHandle(sourceHandleNormalized) ||
+ isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? semanticSourceDefault
+ : semanticSourceDefault;
+ targetHandle =
+ isSemanticTargetHandle(targetHandleNormalized) ||
+ isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? semanticTargetDefault
+ : semanticTargetDefault;
+ } else {
+ sourceHandle = isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultDataSourceHandle(layoutDirection);
+ targetHandle = isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultDataTargetHandle(layoutDirection);
+ }
return {
...edge,
type: semantic ? "semantic" : "canvas",
data: semantic ? edge.data : { ...(edge.data ?? {}), path: "smoothstep" },
- ...handles,
+ sourceHandle,
+ targetHandle,
style: { ...baseStyle, ...(edge.style ?? {}) },
};
}
@@ -349,7 +395,9 @@ export function deriveDisplayGraph({
return {
nodes: [...displayNodes, ...auxNodes],
- edges: [...edges, ...auxEdges].map((edge) => normalizeEdge(edge, configs)),
+ edges: [...edges, ...auxEdges].map((edge) =>
+ normalizeEdge(edge, configs, layoutDirection),
+ ),
auxNodeIds,
auxDefaults,
};
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
index 8817f3c9c5..82450a766e 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
@@ -1,6 +1,11 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../../types";
-import { HANDLE_IDS, normalizeRecipeConnectionHandles } from "../handles";
+import {
+ isDataSourceHandle,
+ isDataTargetHandle,
+ isSemanticSourceHandle,
+ isSemanticTargetHandle,
+} from "../handles";
import { isSemanticRelation } from "./relations";
import {
isCategoryConfig,
@@ -52,18 +57,18 @@ function isModelInfraNode(config: NodeConfig): boolean {
}
function isSemanticLane(connection: Connection): boolean {
- const normalized = normalizeRecipeConnectionHandles(connection);
return (
- normalized.sourceHandle === HANDLE_IDS.semanticOut &&
- normalized.targetHandle === HANDLE_IDS.semanticIn
+ (isSemanticSourceHandle(connection.sourceHandle) ||
+ isDataSourceHandle(connection.sourceHandle)) &&
+ (isSemanticTargetHandle(connection.targetHandle) ||
+ isDataTargetHandle(connection.targetHandle))
);
}
function isDataLane(connection: Connection): boolean {
- const normalized = normalizeRecipeConnectionHandles(connection);
return (
- normalized.sourceHandle === HANDLE_IDS.dataOut &&
- normalized.targetHandle === HANDLE_IDS.dataIn
+ isDataSourceHandle(connection.sourceHandle) &&
+ isDataTargetHandle(connection.targetHandle)
);
}
@@ -126,7 +131,6 @@ export function isValidRecipeConnection(
connection: Connection,
configs: Record,
): boolean {
- const normalizedConnection = normalizeRecipeConnectionHandles(connection);
if (!(connection.source && connection.target)) {
return false;
}
@@ -140,12 +144,12 @@ export function isValidRecipeConnection(
}
const semanticRelation = isSemanticRelation(source, target);
if (semanticRelation) {
- return isSemanticLane(normalizedConnection);
+ return isSemanticLane(connection);
}
if (isModelInfraNode(source) || isModelInfraNode(target)) {
return false;
}
- return isDataLane(normalizedConnection);
+ return isDataLane(connection);
}
export function applyRecipeConnection(
@@ -153,15 +157,14 @@ export function applyRecipeConnection(
configs: Record,
edges: Edge[],
): { edges: Edge[]; configs?: Record } {
- const normalizedConnection = normalizeRecipeConnectionHandles(connection);
- if (!isValidRecipeConnection(normalizedConnection, configs)) {
+ if (!isValidRecipeConnection(connection, configs)) {
return { edges };
}
- const source = normalizedConnection.source
- ? configs[normalizedConnection.source]
+ const source = connection.source
+ ? configs[connection.source]
: null;
- const target = normalizedConnection.target
- ? configs[normalizedConnection.target]
+ const target = connection.target
+ ? configs[connection.target]
: null;
if (!(source && target)) {
return { edges };
@@ -175,7 +178,7 @@ export function applyRecipeConnection(
)
: edges;
const nextEdges = addEdge(
- { ...normalizedConnection, type: semanticRelation ? "semantic" : "canvas" },
+ { ...connection, type: semanticRelation ? "semantic" : "canvas" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
diff --git a/studio/frontend/src/features/recipe-studio/utils/handles.ts b/studio/frontend/src/features/recipe-studio/utils/handles.ts
index f87884f3e7..d7b154cc91 100644
--- a/studio/frontend/src/features/recipe-studio/utils/handles.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/handles.ts
@@ -1,15 +1,26 @@
import type { Connection } from "@xyflow/react";
+import type { LayoutDirection } from "../types";
export const HANDLE_IDS = {
// data flow lanes
dataIn: "data-in",
dataInTop: "data-in-top",
+ dataInRight: "data-in-right",
+ dataInBottom: "data-in-bottom",
dataOut: "data-out",
+ dataOutLeft: "data-out-left",
+ dataOutTop: "data-out-top",
dataOutBottom: "data-out-bottom",
// semantic dependency lanes
semanticIn: "semantic-in",
+ semanticInTop: "semantic-in-top",
+ semanticInRight: "semantic-in-right",
+ semanticInBottom: "semantic-in-bottom",
semanticInLeft: "semantic-in-left",
semanticOut: "semantic-out",
+ semanticOutLeft: "semantic-out-left",
+ semanticOutTop: "semantic-out-top",
+ semanticOutBottom: "semantic-out-bottom",
semanticOutRight: "semantic-out-right",
// llm prompt/scorer lanes
llmPromptIn: "llm-prompt-in",
@@ -23,24 +34,90 @@ export function getLlmJudgeScoreHandleId(index: number): string {
return `llm-judge-score-in-${index}`;
}
-const HANDLE_CANONICAL_MAP: Record = {
- [HANDLE_IDS.dataIn]: HANDLE_IDS.dataIn,
- [HANDLE_IDS.dataInTop]: HANDLE_IDS.dataIn,
- [HANDLE_IDS.dataOut]: HANDLE_IDS.dataOut,
- [HANDLE_IDS.dataOutBottom]: HANDLE_IDS.dataOut,
- [HANDLE_IDS.semanticIn]: HANDLE_IDS.semanticIn,
+const LEGACY_HANDLE_ALIAS_MAP: Record = {
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
- [HANDLE_IDS.semanticOut]: HANDLE_IDS.semanticOut,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,
};
+const DATA_TARGET_HANDLES = new Set([
+ HANDLE_IDS.dataIn,
+ HANDLE_IDS.dataInTop,
+ HANDLE_IDS.dataInRight,
+ HANDLE_IDS.dataInBottom,
+]);
+
+const DATA_SOURCE_HANDLES = new Set([
+ HANDLE_IDS.dataOut,
+ HANDLE_IDS.dataOutLeft,
+ HANDLE_IDS.dataOutTop,
+ HANDLE_IDS.dataOutBottom,
+]);
+
+const SEMANTIC_TARGET_HANDLES = new Set([
+ HANDLE_IDS.semanticIn,
+ HANDLE_IDS.semanticInTop,
+ HANDLE_IDS.semanticInRight,
+ HANDLE_IDS.semanticInBottom,
+ HANDLE_IDS.semanticInLeft,
+]);
+
+const SEMANTIC_SOURCE_HANDLES = new Set([
+ HANDLE_IDS.semanticOut,
+ HANDLE_IDS.semanticOutLeft,
+ HANDLE_IDS.semanticOutTop,
+ HANDLE_IDS.semanticOutBottom,
+ HANDLE_IDS.semanticOutRight,
+]);
+
+const DATA_TARGET_HORIZONTAL_HANDLES = new Set([
+ HANDLE_IDS.dataIn,
+ HANDLE_IDS.dataInRight,
+]);
+
+const DATA_TARGET_VERTICAL_HANDLES = new Set([
+ HANDLE_IDS.dataInTop,
+ HANDLE_IDS.dataInBottom,
+]);
+
+const DATA_SOURCE_HORIZONTAL_HANDLES = new Set([
+ HANDLE_IDS.dataOut,
+ HANDLE_IDS.dataOutLeft,
+]);
+
+const DATA_SOURCE_VERTICAL_HANDLES = new Set([
+ HANDLE_IDS.dataOutTop,
+ HANDLE_IDS.dataOutBottom,
+]);
+
+const SEMANTIC_TARGET_HORIZONTAL_HANDLES = new Set([
+ HANDLE_IDS.semanticIn,
+ HANDLE_IDS.semanticInRight,
+ HANDLE_IDS.semanticInLeft,
+]);
+
+const SEMANTIC_TARGET_VERTICAL_HANDLES = new Set([
+ HANDLE_IDS.semanticInTop,
+ HANDLE_IDS.semanticInBottom,
+]);
+
+const SEMANTIC_SOURCE_HORIZONTAL_HANDLES = new Set([
+ HANDLE_IDS.semanticOut,
+ HANDLE_IDS.semanticOutLeft,
+ HANDLE_IDS.semanticOutRight,
+]);
+
+const SEMANTIC_SOURCE_VERTICAL_HANDLES = new Set([
+ HANDLE_IDS.semanticOutTop,
+ HANDLE_IDS.semanticOutBottom,
+]);
+
export function normalizeRecipeHandleId(
handleId: string | null | undefined,
): string | null {
if (!handleId) {
return null;
}
- return HANDLE_CANONICAL_MAP[handleId] ?? handleId;
+ return LEGACY_HANDLE_ALIAS_MAP[handleId] ?? handleId;
}
export function normalizeRecipeConnectionHandles(
@@ -52,3 +129,144 @@ export function normalizeRecipeConnectionHandles(
targetHandle: normalizeRecipeHandleId(connection.targetHandle),
};
}
+
+function isKnownHandle(
+ handleId: string | null | undefined,
+ handles: Set,
+): boolean {
+ if (!handleId) {
+ return false;
+ }
+ return handles.has(normalizeRecipeHandleId(handleId) ?? "");
+}
+
+function remapHandleForDirection(
+ handleId: string | null | undefined,
+ direction: LayoutDirection,
+ horizontalHandles: Set,
+ verticalHandles: Set,
+ defaultHandle: string,
+): string {
+ const normalizedHandleId = normalizeRecipeHandleId(handleId);
+ if (!normalizedHandleId) {
+ return defaultHandle;
+ }
+ if (direction === "LR") {
+ if (verticalHandles.has(normalizedHandleId)) {
+ return defaultHandle;
+ }
+ return normalizedHandleId;
+ }
+ if (horizontalHandles.has(normalizedHandleId)) {
+ return defaultHandle;
+ }
+ return normalizedHandleId;
+}
+
+export function isDataTargetHandle(
+ handleId: string | null | undefined,
+): boolean {
+ return isKnownHandle(handleId, DATA_TARGET_HANDLES);
+}
+
+export function isDataSourceHandle(
+ handleId: string | null | undefined,
+): boolean {
+ return isKnownHandle(handleId, DATA_SOURCE_HANDLES);
+}
+
+export function isSemanticTargetHandle(
+ handleId: string | null | undefined,
+): boolean {
+ return isKnownHandle(handleId, SEMANTIC_TARGET_HANDLES);
+}
+
+export function isSemanticSourceHandle(
+ handleId: string | null | undefined,
+): boolean {
+ return isKnownHandle(handleId, SEMANTIC_SOURCE_HANDLES);
+}
+
+export function getDefaultDataTargetHandle(direction: LayoutDirection): string {
+ return direction === "TB" ? HANDLE_IDS.dataInTop : HANDLE_IDS.dataIn;
+}
+
+export function getDefaultDataSourceHandle(direction: LayoutDirection): string {
+ return direction === "TB" ? HANDLE_IDS.dataOutBottom : HANDLE_IDS.dataOut;
+}
+
+export function getDefaultSemanticTargetHandle(
+ direction: LayoutDirection,
+): string {
+ return direction === "TB" ? HANDLE_IDS.semanticInTop : HANDLE_IDS.semanticIn;
+}
+
+export function getDefaultSemanticSourceHandle(
+ direction: LayoutDirection,
+): string {
+ return direction === "TB" ? HANDLE_IDS.semanticOutBottom : HANDLE_IDS.semanticOut;
+}
+
+type RecipeEdgeHandles = {
+ sourceHandle?: string | null;
+ targetHandle?: string | null;
+ type?: string | null;
+};
+
+export function remapRecipeEdgeHandlesForLayout(
+ edge: RecipeEdgeHandles,
+ direction: LayoutDirection,
+): { sourceHandle: string; targetHandle: string } {
+ const semantic =
+ edge.type === "semantic" ||
+ (isSemanticSourceHandle(edge.sourceHandle) &&
+ isSemanticTargetHandle(edge.targetHandle));
+ if (semantic) {
+ const sourceIsData = isDataSourceHandle(edge.sourceHandle);
+ const targetIsData = isDataTargetHandle(edge.targetHandle);
+ return {
+ sourceHandle: remapHandleForDirection(
+ edge.sourceHandle,
+ direction,
+ sourceIsData
+ ? DATA_SOURCE_HORIZONTAL_HANDLES
+ : SEMANTIC_SOURCE_HORIZONTAL_HANDLES,
+ sourceIsData
+ ? DATA_SOURCE_VERTICAL_HANDLES
+ : SEMANTIC_SOURCE_VERTICAL_HANDLES,
+ sourceIsData
+ ? getDefaultDataSourceHandle(direction)
+ : getDefaultSemanticSourceHandle(direction),
+ ),
+ targetHandle: remapHandleForDirection(
+ edge.targetHandle,
+ direction,
+ targetIsData
+ ? DATA_TARGET_HORIZONTAL_HANDLES
+ : SEMANTIC_TARGET_HORIZONTAL_HANDLES,
+ targetIsData
+ ? DATA_TARGET_VERTICAL_HANDLES
+ : SEMANTIC_TARGET_VERTICAL_HANDLES,
+ targetIsData
+ ? getDefaultDataTargetHandle(direction)
+ : getDefaultSemanticTargetHandle(direction),
+ ),
+ };
+ }
+ return {
+ sourceHandle: remapHandleForDirection(
+ edge.sourceHandle,
+ direction,
+ DATA_SOURCE_HORIZONTAL_HANDLES,
+ DATA_SOURCE_VERTICAL_HANDLES,
+ getDefaultDataSourceHandle(direction),
+ ),
+ targetHandle: remapHandleForDirection(
+ edge.targetHandle,
+ direction,
+ DATA_TARGET_HORIZONTAL_HANDLES,
+ DATA_TARGET_VERTICAL_HANDLES,
+ getDefaultDataTargetHandle(direction),
+ ),
+ };
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts
index a412ff5a23..59e022063d 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/edges.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/edges.ts
@@ -1,6 +1,16 @@
import type { Edge } from "@xyflow/react";
-import type { NodeConfig } from "../../types";
-import { HANDLE_IDS } from "../handles";
+import type { LayoutDirection, NodeConfig } from "../../types";
+import {
+ getDefaultDataSourceHandle,
+ getDefaultDataTargetHandle,
+ getDefaultSemanticSourceHandle,
+ getDefaultSemanticTargetHandle,
+ isDataSourceHandle,
+ isDataTargetHandle,
+ isSemanticSourceHandle,
+ isSemanticTargetHandle,
+ normalizeRecipeHandleId,
+} from "../handles";
import { extractRefs } from "./helpers";
function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
@@ -13,12 +23,26 @@ function isSemanticConnection(source: NodeConfig, target: NodeConfig): boolean {
export function buildEdges(
configs: NodeConfig[],
nameToId: Map,
- uiEdges: Array<{ from: string; to: string; type?: string }> | null,
+ uiEdges:
+ | Array<{
+ from: string;
+ to: string;
+ type?: string;
+ sourceHandle?: string;
+ targetHandle?: string;
+ }>
+ | null,
+ layoutDirection: LayoutDirection,
): Edge[] {
const edges: Edge[] = [];
const seen = new Set();
const configByName = new Map(configs.map((config) => [config.name, config]));
- const addEdgeByName = (from: string, to: string) => {
+ const addEdgeByName = (
+ from: string,
+ to: string,
+ sourceHandleInput?: string,
+ targetHandleInput?: string,
+ ): void => {
const sourceId = nameToId.get(from);
const targetId = nameToId.get(to);
if (!(sourceId && targetId)) {
@@ -35,28 +59,56 @@ export function buildEdges(
source && target && isSemanticConnection(source, target),
);
const normalizedType = isSemantic ? "semantic" : "canvas";
- const handles =
- normalizedType === "semantic"
- ? {
- sourceHandle: HANDLE_IDS.semanticOut,
- targetHandle: HANDLE_IDS.semanticIn,
- }
- : {
- sourceHandle: HANDLE_IDS.dataOut,
- targetHandle: HANDLE_IDS.dataIn,
- };
+ const sourceHandleNormalized = normalizeRecipeHandleId(sourceHandleInput);
+ const targetHandleNormalized = normalizeRecipeHandleId(targetHandleInput);
+ const semanticSourceDefault =
+ source?.kind === "llm"
+ ? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultSemanticSourceHandle(layoutDirection);
+ const semanticTargetDefault =
+ target?.kind === "llm"
+ ? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultSemanticTargetHandle(layoutDirection);
+ let sourceHandle = getDefaultDataSourceHandle(layoutDirection);
+ let targetHandle = getDefaultDataTargetHandle(layoutDirection);
+
+ if (isSemantic) {
+ sourceHandle =
+ isSemanticSourceHandle(sourceHandleNormalized) ||
+ isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? semanticSourceDefault
+ : semanticSourceDefault;
+ targetHandle =
+ isSemanticTargetHandle(targetHandleNormalized) ||
+ isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? semanticTargetDefault
+ : semanticTargetDefault;
+ } else {
+ sourceHandle = isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultDataSourceHandle(layoutDirection);
+ targetHandle = isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultDataTargetHandle(layoutDirection);
+ }
edges.push({
id: `e-${key}`,
source: sourceId,
target: targetId,
type: normalizedType,
- ...handles,
+ sourceHandle,
+ targetHandle,
});
};
if (uiEdges && uiEdges.length > 0) {
for (const edge of uiEdges) {
- addEdgeByName(edge.from, edge.to);
+ addEdgeByName(
+ edge.from,
+ edge.to,
+ edge.sourceHandle,
+ edge.targetHandle,
+ );
}
if (edges.length > 0) {
return edges;
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 81caed7e03..a02bdb473b 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
@@ -398,9 +398,15 @@ export function importRecipePayload(input: string): ImportResult {
return { errors, snapshot: null };
}
- const { layouts, edges: uiEdges } = parseUi(ui);
+ const { layouts, edges: uiEdges, layoutDirection } = parseUi(ui);
+ const resolvedLayoutDirection = layoutDirection ?? "LR";
const nodes = buildNodes(configs, layouts);
- const edges = buildEdges(configs, nameToId, uiEdges);
+ const edges = buildEdges(
+ configs,
+ nameToId,
+ uiEdges,
+ resolvedLayoutDirection,
+ );
const maxY = nodes.reduce(
(acc, node) => Math.max(acc, node.position.y),
@@ -414,6 +420,7 @@ export function importRecipePayload(input: string): ImportResult {
nodes,
edges,
processors,
+ layoutDirection: resolvedLayoutDirection,
nextId,
nextY: maxY + 140,
},
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 bc9d151f7f..9b5502a5ba 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/types.ts
@@ -1,5 +1,6 @@
import type { Edge } from "@xyflow/react";
import type {
+ LayoutDirection,
RecipeNode,
RecipeProcessorConfig,
NodeConfig,
@@ -10,6 +11,7 @@ export type RecipeSnapshot = {
nodes: RecipeNode[];
edges: Edge[];
processors: RecipeProcessorConfig[];
+ layoutDirection: LayoutDirection;
nextId: number;
nextY: 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 ece4c4a1b5..102fb4016e 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/ui.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/ui.ts
@@ -1,21 +1,37 @@
import type { RecipeNode, NodeConfig } from "../../types";
import { DEFAULT_NODE_WIDTH } from "../../constants";
import { nodeDataFromConfig } from "../index";
+import { normalizeRecipeHandleId } from "../handles";
import { isRecord, readString } from "./helpers";
type UiInput = {
nodes?: unknown;
edges?: unknown;
+ layout_direction?: unknown;
+ layoutDirection?: unknown;
};
export function parseUi(
ui: UiInput | null,
): {
layouts: Map;
- edges: Array<{ from: string; to: string; type?: string }> | null;
+ edges: Array<{
+ from: string;
+ to: string;
+ type?: string;
+ sourceHandle?: string;
+ targetHandle?: string;
+ }> | null;
+ layoutDirection: "LR" | "TB" | null;
} {
const layouts = new Map();
- const edges: Array<{ from: string; to: string; type?: string }> = [];
+ const edges: Array<{
+ from: string;
+ to: string;
+ type?: string;
+ sourceHandle?: string;
+ targetHandle?: string;
+ }> = [];
if (ui && Array.isArray(ui.nodes)) {
for (const node of ui.nodes) {
if (isRecord(node)) {
@@ -39,16 +55,33 @@ export function parseUi(
const from = readString(edge.from);
const to = readString(edge.to);
if (from && to) {
+ const sourceHandle = normalizeRecipeHandleId(
+ readString(edge.source_handle) ?? readString(edge.sourceHandle),
+ );
+ const targetHandle = normalizeRecipeHandleId(
+ readString(edge.target_handle) ?? readString(edge.targetHandle),
+ );
edges.push({
from,
to,
type: readString(edge.type) ?? undefined,
+ sourceHandle: sourceHandle ?? undefined,
+ targetHandle: targetHandle ?? undefined,
});
}
}
}
}
- return { layouts, edges: edges.length > 0 ? edges : null };
+ const layoutDirectionRaw =
+ readString(ui?.layout_direction) ?? readString(ui?.layoutDirection);
+ const layoutDirection =
+ layoutDirectionRaw === "TB"
+ ? "TB"
+ : layoutDirectionRaw === "LR"
+ ? "LR"
+ : null;
+
+ return { layouts, 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 52c70e5316..583de3cfa1 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,5 +1,6 @@
import type { Edge } from "@xyflow/react";
import type {
+ LayoutDirection,
ModelConfig,
ModelProviderConfig,
NodeConfig,
@@ -8,6 +9,17 @@ import type {
} from "../../types";
import { isSemanticRelation } from "../graph/relations";
import { getConfigErrors } from "../index";
+import {
+ getDefaultDataSourceHandle,
+ getDefaultDataTargetHandle,
+ getDefaultSemanticSourceHandle,
+ getDefaultSemanticTargetHandle,
+ isDataSourceHandle,
+ isDataTargetHandle,
+ isSemanticSourceHandle,
+ isSemanticTargetHandle,
+ normalizeRecipeHandleId,
+} from "../handles";
import { readNodeWidth } from "../rf-node-dimensions";
import {
buildExpressionColumn,
@@ -57,6 +69,7 @@ export function buildRecipePayload(
nodes: RecipeNode[],
edges: Edge[],
processors: RecipeProcessorConfig[] = [],
+ layoutDirection: LayoutDirection = "LR",
): RecipePayloadResult {
const errors: string[] = [];
const columns: Record[] = [];
@@ -192,14 +205,47 @@ export function buildRecipePayload(
if (!(source && target)) {
return [];
}
+ const semantic =
+ edge.type === "semantic" || isSemanticRelation(source, target);
+ const sourceHandleNormalized = normalizeRecipeHandleId(edge.sourceHandle);
+ const targetHandleNormalized = normalizeRecipeHandleId(edge.targetHandle);
+ const semanticSourceDefault =
+ source.kind === "llm"
+ ? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultSemanticSourceHandle(layoutDirection);
+ const semanticTargetDefault =
+ target.kind === "llm"
+ ? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultSemanticTargetHandle(layoutDirection);
+ let sourceHandle = getDefaultDataSourceHandle(layoutDirection);
+ let targetHandle = getDefaultDataTargetHandle(layoutDirection);
+
+ if (semantic) {
+ sourceHandle =
+ isSemanticSourceHandle(sourceHandleNormalized) ||
+ isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? semanticSourceDefault
+ : semanticSourceDefault;
+ targetHandle =
+ isSemanticTargetHandle(targetHandleNormalized) ||
+ isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? semanticTargetDefault
+ : semanticTargetDefault;
+ } else {
+ sourceHandle = isDataSourceHandle(sourceHandleNormalized)
+ ? sourceHandleNormalized ?? getDefaultDataSourceHandle(layoutDirection)
+ : getDefaultDataSourceHandle(layoutDirection);
+ targetHandle = isDataTargetHandle(targetHandleNormalized)
+ ? targetHandleNormalized ?? getDefaultDataTargetHandle(layoutDirection)
+ : getDefaultDataTargetHandle(layoutDirection);
+ }
return [
{
from: source.name,
to: target.name,
- type:
- edge.type === "semantic" || isSemanticRelation(source, target)
- ? "semantic"
- : "canvas",
+ type: semantic ? "semantic" : "canvas",
+ source_handle: sourceHandle ?? undefined,
+ target_handle: targetHandle ?? undefined,
},
];
});
@@ -238,6 +284,7 @@ export function buildRecipePayload(
ui: {
nodes: uiNodes,
edges: uiEdges,
+ layout_direction: layoutDirection,
...(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/empty.ts b/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts
index b60bf31e72..d0ec917321 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts
@@ -23,7 +23,7 @@ export function createEmptyRecipePayload(): RecipePayload {
ui: {
nodes: [],
edges: [],
+ layout_direction: "LR",
},
};
}
-
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 adae858218..c407431288 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
@@ -31,7 +31,15 @@ export type RecipePayload = {
};
ui: {
nodes: { id: string; x: number; y: number }[];
- edges: { from: string; to: string; type?: string }[];
+ edges: {
+ from: string;
+ to: string;
+ type?: string;
+ source_handle?: string;
+ target_handle?: string;
+ }[];
+ // ui-only: graph orientation
+ layout_direction?: "LR" | "TB";
// ui-only, used to preserve seed block mode across imports/refresh
seed_source_type?: "hf" | "local" | "unstructured";
// ui-only, seed metadata cached for refresh/import UX