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:
parent
9f574941f9
commit
5254f04065
13 changed files with 504 additions and 79 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 && {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function createEmptyRecipePayload(): RecipePayload {
|
|||
ui: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
layout_direction: "LR",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue