feat: add layout direction support and enhance handle logic

- Introduced `layoutDirection` to control graph orientation ("LR" or "TB") and integrate into edges, nodes, and payloads.
- Enhanced handle management with new default, semantic, and data-specific mappings based on layout direction.
- Added handle normalization for consistent connections across layouts and semantic/data flows.
- Updated UI to reflect layout-aware positioning and semantic connections.
This commit is contained in:
Shine1i 2026-02-24 00:51:49 +01:00
commit 5254f04065
13 changed files with 504 additions and 79 deletions

View file

@ -278,13 +278,13 @@ function LlmInputHandles({
key={item.id}
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
>
<Handle
id={item.id}
type="target"
position={Position.Top}
className={NODE_HANDLE_CLASS}
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<Handle
id={item.id}
type="target"
position={Position.Top}
className={NODE_HANDLE_CLASS}
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
@ -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}
/>
<LabeledHandle
id={HANDLE_IDS.semanticInLeft}
id={HANDLE_IDS.semanticInTop}
title="Semantic input"
type="target"
position={Position.Left}
position={Position.Top}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
@ -491,16 +492,16 @@ function RecipeGraphNodeBase({
id={HANDLE_IDS.semanticOut}
title="Semantic output"
type="source"
position={Position.Bottom}
position={Position.Right}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.semanticOutRight}
id={HANDLE_IDS.semanticOutBottom}
title="Semantic output"
type="source"
position={Position.Right}
position={Position.Bottom}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}

View file

@ -265,8 +265,8 @@ export function RecipeStudioPage({
}, []);
const payloadResult = useMemo(
() => 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 {

View file

@ -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<RecipeStudioState>((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<RecipeStudioState>((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: {},

View file

@ -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<string, XYPosition>;
};
function normalizeEdge(edge: Edge, configs: Record<string, NodeConfig>): Edge {
function normalizeEdge(
edge: Edge,
configs: Record<string, NodeConfig>,
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<string, NodeConfig>): 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,
};

View file

@ -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<string, NodeConfig>,
): 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<string, NodeConfig>,
edges: Edge[],
): { edges: Edge[]; configs?: Record<string, NodeConfig> } {
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") {

View file

@ -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<string, string> = {
[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<string, string> = {
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticOut]: HANDLE_IDS.semanticOut,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,
};
const DATA_TARGET_HANDLES = new Set<string>([
HANDLE_IDS.dataIn,
HANDLE_IDS.dataInTop,
HANDLE_IDS.dataInRight,
HANDLE_IDS.dataInBottom,
]);
const DATA_SOURCE_HANDLES = new Set<string>([
HANDLE_IDS.dataOut,
HANDLE_IDS.dataOutLeft,
HANDLE_IDS.dataOutTop,
HANDLE_IDS.dataOutBottom,
]);
const SEMANTIC_TARGET_HANDLES = new Set<string>([
HANDLE_IDS.semanticIn,
HANDLE_IDS.semanticInTop,
HANDLE_IDS.semanticInRight,
HANDLE_IDS.semanticInBottom,
HANDLE_IDS.semanticInLeft,
]);
const SEMANTIC_SOURCE_HANDLES = new Set<string>([
HANDLE_IDS.semanticOut,
HANDLE_IDS.semanticOutLeft,
HANDLE_IDS.semanticOutTop,
HANDLE_IDS.semanticOutBottom,
HANDLE_IDS.semanticOutRight,
]);
const DATA_TARGET_HORIZONTAL_HANDLES = new Set<string>([
HANDLE_IDS.dataIn,
HANDLE_IDS.dataInRight,
]);
const DATA_TARGET_VERTICAL_HANDLES = new Set<string>([
HANDLE_IDS.dataInTop,
HANDLE_IDS.dataInBottom,
]);
const DATA_SOURCE_HORIZONTAL_HANDLES = new Set<string>([
HANDLE_IDS.dataOut,
HANDLE_IDS.dataOutLeft,
]);
const DATA_SOURCE_VERTICAL_HANDLES = new Set<string>([
HANDLE_IDS.dataOutTop,
HANDLE_IDS.dataOutBottom,
]);
const SEMANTIC_TARGET_HORIZONTAL_HANDLES = new Set<string>([
HANDLE_IDS.semanticIn,
HANDLE_IDS.semanticInRight,
HANDLE_IDS.semanticInLeft,
]);
const SEMANTIC_TARGET_VERTICAL_HANDLES = new Set<string>([
HANDLE_IDS.semanticInTop,
HANDLE_IDS.semanticInBottom,
]);
const SEMANTIC_SOURCE_HORIZONTAL_HANDLES = new Set<string>([
HANDLE_IDS.semanticOut,
HANDLE_IDS.semanticOutLeft,
HANDLE_IDS.semanticOutRight,
]);
const SEMANTIC_SOURCE_VERTICAL_HANDLES = new Set<string>([
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<string>,
): boolean {
if (!handleId) {
return false;
}
return handles.has(normalizeRecipeHandleId(handleId) ?? "");
}
function remapHandleForDirection(
handleId: string | null | undefined,
direction: LayoutDirection,
horizontalHandles: Set<string>,
verticalHandles: Set<string>,
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),
),
};
}

View file

@ -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<string, string>,
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<string>();
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;

View file

@ -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,
},

View file

@ -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;
};

View file

@ -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<string, { x: number; y: number; width?: number }>;
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<string, { x: number; y: number; width?: number }>();
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(

View file

@ -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<string, unknown>[] = [];
@ -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 && {

View file

@ -23,7 +23,7 @@ export function createEmptyRecipePayload(): RecipePayload {
ui: {
nodes: [],
edges: [],
layout_direction: "LR",
},
};
}

View file

@ -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