feat(recipe-studio): enhance edge synchronization logic with layout direction support

This commit is contained in:
Shine1i 2026-03-03 22:17:50 +01:00
commit 2473043fe1
5 changed files with 103 additions and 27 deletions

View file

@ -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<NodeConfig>,
configs: Record<string, NodeConfig>,
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<ModelConfig>).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<NodeConfig> & { 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;
}
}
}

View file

@ -80,6 +80,18 @@ function isConfigToLlmEdge(edge: Edge, configs: Record<string, NodeConfig>): boo
return source?.kind === "model_config" && target?.kind === "llm";
}
function isDataToLlmEdge(edge: Edge, configs: Record<string, NodeConfig>): 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(

View file

@ -223,6 +223,7 @@ function connectSemantic(
configs: Record<string, NodeConfig>,
sourceId: string,
targetId: string,
layoutDirection: LayoutDirection,
): { edges: Edge[]; configs: Record<string, NodeConfig> } {
const result = applyRecipeConnection(
{
@ -233,6 +234,7 @@ function connectSemantic(
},
configs,
edges,
layoutDirection,
);
return {
edges: result.edges,
@ -460,6 +462,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
configs,
context.newNodeId,
unboundModelConfigs[0].id,
state.layoutDirection,
);
edges = next.edges;
configs = next.configs;
@ -513,6 +516,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
configs,
providers[0].id,
context.newNodeId,
state.layoutDirection,
);
edges = next.edges;
configs = next.configs;
@ -523,6 +527,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
configs,
context.newNodeId,
unboundLlms[0].id,
state.layoutDirection,
);
edges = next.edges;
configs = next.configs;
@ -624,7 +629,13 @@ export const useRecipeStudioStore = create<RecipeStudioState>((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<RecipeStudioState>((set, get) => ({
connection,
state.configs,
state.edges,
state.layoutDirection,
);
return result.configs
? { edges: result.edges, configs: result.configs }

View file

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

View file

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