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