From 2473043fe1d41dfc47b4350bc54ad7312d748ed8 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Tue, 3 Mar 2026 22:17:50 +0100 Subject: [PATCH] feat(recipe-studio): enhance edge synchronization logic with layout direction support --- .../recipe-studio/stores/helpers/edge-sync.ts | 48 +++++++++++++------ .../stores/helpers/model-infra-layout.ts | 39 ++++++++++++--- .../recipe-studio/stores/recipe-studio.ts | 14 +++++- .../utils/graph/derive-display-graph.ts | 7 +++ .../utils/graph/recipe-graph-connection.ts | 22 +++++++-- 5 files changed, 103 insertions(+), 27 deletions(-) diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts index 112a7c849b..cf4812959f 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/edge-sync.ts @@ -1,10 +1,12 @@ import { type Edge, addEdge } from "@xyflow/react"; import type { + LayoutDirection, ModelConfig, NodeConfig, SamplerConfig, ValidatorConfig, } from "../../types"; +import { applyRecipeConnection } from "../../utils/graph"; import { isCategoryConfig, isSubcategoryConfig } from "../../utils"; import { HANDLE_IDS } from "../../utils/handles"; @@ -31,19 +33,6 @@ function addRecipeEdge(edges: Edge[], source: string, target: string): Edge[] { ); } -function addSemanticEdge(edges: Edge[], source: string, target: string): Edge[] { - return addEdge( - { - source, - target, - sourceHandle: HANDLE_IDS.semanticOut, - targetHandle: HANDLE_IDS.semanticIn, - type: "semantic", - }, - edges, - ); -} - function addValidatorSemanticEdge( edges: Edge[], source: string, @@ -84,6 +73,7 @@ export function syncEdgesForConfigPatch( patch: Partial, configs: Record, edges: Edge[], + layoutDirection: LayoutDirection, ): Edge[] { let nextEdges = edges; @@ -106,6 +96,9 @@ export function syncEdgesForConfigPatch( ); if (current.kind === "model_config" && hasProviderPatch) { const nextProvider = (patch as Partial).provider ?? ""; + if (nextProvider.trim() === current.provider.trim()) { + return nextEdges; + } nextEdges = removeTargetEdgesBySource( nextEdges, configs, @@ -115,7 +108,18 @@ export function syncEdgesForConfigPatch( if (nextProvider) { const providerId = findNodeIdByName(configs, nextProvider); if (providerId) { - nextEdges = addSemanticEdge(nextEdges, providerId, current.id); + const result = applyRecipeConnection( + { + source: providerId, + sourceHandle: HANDLE_IDS.semanticOut, + target: current.id, + targetHandle: HANDLE_IDS.semanticIn, + }, + configs, + nextEdges, + layoutDirection, + ); + nextEdges = result.edges; } } } @@ -163,6 +167,9 @@ export function syncEdgesForConfigPatch( if (current.kind === "llm" && hasModelAliasPatch) { const nextAlias = (patch as Partial & { model_alias?: string }).model_alias ?? ""; + if (nextAlias.trim() === current.model_alias.trim()) { + return nextEdges; + } nextEdges = removeTargetEdgesBySource( nextEdges, configs, @@ -172,7 +179,18 @@ export function syncEdgesForConfigPatch( if (nextAlias) { const modelConfigId = findNodeIdByName(configs, nextAlias); if (modelConfigId) { - nextEdges = addSemanticEdge(nextEdges, modelConfigId, current.id); + const result = applyRecipeConnection( + { + source: modelConfigId, + sourceHandle: HANDLE_IDS.semanticOut, + target: current.id, + targetHandle: HANDLE_IDS.semanticIn, + }, + configs, + nextEdges, + layoutDirection, + ); + nextEdges = result.edges; } } } diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts index 1def42bfbf..ac30da9278 100644 --- a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts @@ -80,6 +80,18 @@ function isConfigToLlmEdge(edge: Edge, configs: Record): boo return source?.kind === "model_config" && target?.kind === "llm"; } +function isDataToLlmEdge(edge: Edge, configs: Record): boolean { + const source = configs[edge.source]; + const target = configs[edge.target]; + return Boolean( + source && + target && + source.kind !== "model_config" && + target.kind === "llm" && + edge.type !== "semantic", + ); +} + function usageKey(nodeId: string, handleId: string): string { return `${nodeId}::${handleId}`; } @@ -236,15 +248,20 @@ export function optimizeModelInfraEdgeHandles( const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle); const isModelSemantic = isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs); - if (!isModelSemantic) { + const isLlmDataTarget = isDataToLlmEdge(edge, configs); + if (!isModelSemantic && !isLlmDataTarget) { nextEdges.push(edge); continue; } - if (sourceHandleBefore) { - decrementUsage(sourceUsage, edge.source, sourceHandleBefore); - } - if (targetHandleBefore) { + if (isModelSemantic) { + if (sourceHandleBefore) { + decrementUsage(sourceUsage, edge.source, sourceHandleBefore); + } + if (targetHandleBefore) { + decrementUsage(targetUsage, edge.target, targetHandleBefore); + } + } else if (targetHandleBefore) { decrementUsage(targetUsage, edge.target, targetHandleBefore); } @@ -265,12 +282,22 @@ export function optimizeModelInfraEdgeHandles( continue; } - const sourceCandidates = getConfigSourceHandleCandidates(direction); const targetCandidates = sortPreferredLlmTargetHandles( direction, nodesById.get(edge.source), nodesById.get(edge.target), ); + if (isLlmDataTarget) { + const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage); + incrementUsage(targetUsage, edge.target, targetHandle); + nextEdges.push({ + ...edge, + targetHandle, + }); + continue; + } + + const sourceCandidates = getConfigSourceHandleCandidates(direction); const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage); const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage); nextEdges.push( 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 f88b117af8..5183c725ab 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -223,6 +223,7 @@ function connectSemantic( configs: Record, sourceId: string, targetId: string, + layoutDirection: LayoutDirection, ): { edges: Edge[]; configs: Record } { const result = applyRecipeConnection( { @@ -233,6 +234,7 @@ function connectSemantic( }, configs, edges, + layoutDirection, ); return { edges: result.edges, @@ -460,6 +462,7 @@ export const useRecipeStudioStore = create((set, get) => ({ configs, context.newNodeId, unboundModelConfigs[0].id, + state.layoutDirection, ); edges = next.edges; configs = next.configs; @@ -513,6 +516,7 @@ export const useRecipeStudioStore = create((set, get) => ({ configs, providers[0].id, context.newNodeId, + state.layoutDirection, ); edges = next.edges; configs = next.configs; @@ -523,6 +527,7 @@ export const useRecipeStudioStore = create((set, get) => ({ configs, context.newNodeId, unboundLlms[0].id, + state.layoutDirection, ); edges = next.edges; configs = next.configs; @@ -624,7 +629,13 @@ export const useRecipeStudioStore = create((set, get) => ({ next, state.layoutDirection, ); - const edges = syncEdgesForConfigPatch(current, patch, configs, state.edges); + const edges = syncEdgesForConfigPatch( + current, + patch, + configs, + state.edges, + state.layoutDirection, + ); configs = syncSubcategoryConfigsForCategoryUpdate( current, next, @@ -698,6 +709,7 @@ export const useRecipeStudioStore = create((set, get) => ({ connection, state.configs, state.edges, + state.layoutDirection, ); return result.configs ? { edges: result.edges, configs: result.configs } 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 4f95d6fb85..79ff76c772 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 @@ -87,6 +87,13 @@ function normalizeEdge( isDataTargetHandle(targetHandleNormalized) ? targetHandleNormalized ?? semanticTargetDefault : semanticTargetDefault; + // LLM nodes only expose data lane handles; coerce legacy semantic handles. + if (source?.kind === "llm" && isSemanticSourceHandle(sourceHandle)) { + sourceHandle = semanticSourceDefault; + } + if (target?.kind === "llm" && isSemanticTargetHandle(targetHandle)) { + targetHandle = semanticTargetDefault; + } } else { sourceHandle = isDataSourceHandle(sourceHandleNormalized) ? sourceHandleNormalized ?? getDefaultDataSourceHandle(layoutDirection) 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 0cfb9a3fcd..82bf105e4d 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,5 +1,5 @@ import { type Connection, type Edge, addEdge } from "@xyflow/react"; -import type { NodeConfig, SamplerConfig } from "../../types"; +import type { LayoutDirection, NodeConfig, SamplerConfig } from "../../types"; import { HANDLE_IDS, isDataSourceHandle, @@ -197,20 +197,30 @@ function chooseModelSemanticHandles( source: NodeConfig, target: NodeConfig, edges: Edge[], + layoutDirection: LayoutDirection, ): Connection { if (!isModelSemanticRelation(source, target)) { return connection; } - const sourceCandidates = [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom]; + const sourceCandidates = + source.kind === "model_config" && target.kind === "llm" + ? layoutDirection === "TB" + ? [HANDLE_IDS.semanticOut] + : [HANDLE_IDS.semanticOutBottom] + : layoutDirection === "TB" + ? [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom] + : [HANDLE_IDS.semanticOutBottom, HANDLE_IDS.semanticOut]; const targetCandidates = target.kind === "model_config" - ? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop] + ? layoutDirection === "TB" + ? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop] + : [HANDLE_IDS.semanticInTop, HANDLE_IDS.semanticIn] : [ - HANDLE_IDS.dataIn, HANDLE_IDS.dataInTop, - HANDLE_IDS.dataInRight, HANDLE_IDS.dataInBottom, + HANDLE_IDS.dataIn, + HANDLE_IDS.dataInRight, ]; const sourceHandle = pickLeastUsedHandle( @@ -281,6 +291,7 @@ export function applyRecipeConnection( connection: Connection, configs: Record, edges: Edge[], + layoutDirection: LayoutDirection = "LR", ): { edges: Edge[]; configs?: Record } { if (!isValidRecipeConnection(connection, configs)) { return { edges }; @@ -322,6 +333,7 @@ export function applyRecipeConnection( source, target, nextBaseEdges, + layoutDirection, ); const nextEdges = addEdge( { ...resolvedConnection, type: semanticRelation ? "semantic" : "canvas" },