feat(recipe-studio): optimize model infra auto-layout handles and centering
This commit is contained in:
parent
dd3e1e7293
commit
d1047646a9
4 changed files with 552 additions and 13 deletions
|
|
@ -0,0 +1,397 @@
|
|||
import type { Edge, XYPosition } from "@xyflow/react";
|
||||
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
|
||||
import type { LayoutDirection, NodeConfig, RecipeNode } from "../../types";
|
||||
import { HANDLE_IDS, normalizeRecipeHandleId } from "../../utils/handles";
|
||||
import { readNodeHeight, readNodeWidth } from "../../utils/rf-node-dimensions";
|
||||
|
||||
type Rect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Bounds = {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
function toRect(node: RecipeNode): Rect {
|
||||
return {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
width: readNodeWidth(node) ?? DEFAULT_NODE_WIDTH,
|
||||
height: readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
function intersects(a: Rect, b: Rect, pad = 18): boolean {
|
||||
return !(
|
||||
a.x + a.width + pad <= b.x ||
|
||||
b.x + b.width + pad <= a.x ||
|
||||
a.y + a.height + pad <= b.y ||
|
||||
b.y + b.height + pad <= a.y
|
||||
);
|
||||
}
|
||||
|
||||
function findNonOverlappingPosition(
|
||||
preferred: XYPosition,
|
||||
width: number,
|
||||
height: number,
|
||||
occupied: Rect[],
|
||||
): XYPosition {
|
||||
const step = 24;
|
||||
for (let ring = 0; ring <= 16; ring += 1) {
|
||||
for (let dx = -ring; dx <= ring; dx += 1) {
|
||||
for (let dy = -ring; dy <= ring; dy += 1) {
|
||||
if (ring > 0 && Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
|
||||
continue;
|
||||
}
|
||||
const candidate = {
|
||||
x: preferred.x + dx * step,
|
||||
y: preferred.y + dy * step,
|
||||
};
|
||||
const rect = {
|
||||
x: candidate.x,
|
||||
y: candidate.y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
if (!occupied.some((item) => intersects(rect, item))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
function isProviderToConfigEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
|
||||
const source = configs[edge.source];
|
||||
const target = configs[edge.target];
|
||||
return source?.kind === "model_provider" && target?.kind === "model_config";
|
||||
}
|
||||
|
||||
function isConfigToLlmEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
|
||||
const source = configs[edge.source];
|
||||
const target = configs[edge.target];
|
||||
return source?.kind === "model_config" && target?.kind === "llm";
|
||||
}
|
||||
|
||||
function usageKey(nodeId: string, handleId: string): string {
|
||||
return `${nodeId}::${handleId}`;
|
||||
}
|
||||
|
||||
function incrementUsage(map: Map<string, number>, nodeId: string, handleId: string): void {
|
||||
const key = usageKey(nodeId, handleId);
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function decrementUsage(map: Map<string, number>, nodeId: string, handleId: string): void {
|
||||
const key = usageKey(nodeId, handleId);
|
||||
map.set(key, Math.max(0, (map.get(key) ?? 0) - 1));
|
||||
}
|
||||
|
||||
function getUsage(map: Map<string, number>, nodeId: string, handleId: string): number {
|
||||
return map.get(usageKey(nodeId, handleId)) ?? 0;
|
||||
}
|
||||
|
||||
function pickHandleByUsage(
|
||||
candidates: string[],
|
||||
nodeId: string,
|
||||
usageMap: Map<string, number>,
|
||||
): string {
|
||||
const free = candidates.filter((handleId) => getUsage(usageMap, nodeId, handleId) === 0);
|
||||
if (free.length > 0) {
|
||||
return free[0];
|
||||
}
|
||||
let bestHandle = candidates[0];
|
||||
let bestCount = Number.POSITIVE_INFINITY;
|
||||
for (const handleId of candidates) {
|
||||
const count = getUsage(usageMap, nodeId, handleId);
|
||||
if (count < bestCount) {
|
||||
bestHandle = handleId;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return bestHandle;
|
||||
}
|
||||
|
||||
function applyEdgeWithHandles(
|
||||
edge: Edge,
|
||||
sourceHandle: string,
|
||||
targetHandle: string,
|
||||
sourceUsage: Map<string, number>,
|
||||
targetUsage: Map<string, number>,
|
||||
): Edge {
|
||||
incrementUsage(sourceUsage, edge.source, sourceHandle);
|
||||
incrementUsage(targetUsage, edge.target, targetHandle);
|
||||
return { ...edge, sourceHandle, targetHandle, type: "semantic" };
|
||||
}
|
||||
|
||||
function getNodeCenter(node: RecipeNode): { x: number; y: number } {
|
||||
const width = readNodeWidth(node) ?? DEFAULT_NODE_WIDTH;
|
||||
const height = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT;
|
||||
return {
|
||||
x: node.position.x + width / 2,
|
||||
y: node.position.y + height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function collectBounds(ids: string[], nodesById: Map<string, RecipeNode>): Bounds | null {
|
||||
const rects = ids
|
||||
.map((id) => nodesById.get(id))
|
||||
.flatMap((node) => (node ? [toRect(node)] : []));
|
||||
if (rects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return rects.reduce<Bounds>(
|
||||
(acc, rect) => ({
|
||||
minX: Math.min(acc.minX, rect.x),
|
||||
maxX: Math.max(acc.maxX, rect.x + rect.width),
|
||||
minY: Math.min(acc.minY, rect.y),
|
||||
maxY: Math.max(acc.maxY, rect.y + rect.height),
|
||||
}),
|
||||
{
|
||||
minX: rects[0].x,
|
||||
maxX: rects[0].x + rects[0].width,
|
||||
minY: rects[0].y,
|
||||
maxY: rects[0].y + rects[0].height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function sortPreferredLlmTargetHandles(
|
||||
direction: LayoutDirection,
|
||||
sourceNode: RecipeNode | undefined,
|
||||
targetNode: RecipeNode | undefined,
|
||||
): string[] {
|
||||
const sourceCenter = sourceNode ? getNodeCenter(sourceNode) : { x: 0, y: 0 };
|
||||
const targetCenter = targetNode ? getNodeCenter(targetNode) : { x: 0, y: 0 };
|
||||
|
||||
if (direction === "TB") {
|
||||
const horizontalFirst =
|
||||
sourceCenter.x <= targetCenter.x
|
||||
? [HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight]
|
||||
: [HANDLE_IDS.dataInRight, HANDLE_IDS.dataIn];
|
||||
return [...horizontalFirst, HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom];
|
||||
}
|
||||
|
||||
const verticalFirst =
|
||||
sourceCenter.y <= targetCenter.y
|
||||
? [HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom]
|
||||
: [HANDLE_IDS.dataInBottom, HANDLE_IDS.dataInTop];
|
||||
return [...verticalFirst, HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight];
|
||||
}
|
||||
|
||||
function getProviderSourceHandleCandidates(direction: LayoutDirection): string[] {
|
||||
return direction === "TB"
|
||||
? [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom]
|
||||
: [HANDLE_IDS.semanticOutBottom, HANDLE_IDS.semanticOut];
|
||||
}
|
||||
|
||||
function getProviderTargetHandleCandidates(direction: LayoutDirection): string[] {
|
||||
return direction === "TB"
|
||||
? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
|
||||
: [HANDLE_IDS.semanticInTop, HANDLE_IDS.semanticIn];
|
||||
}
|
||||
|
||||
function getConfigSourceHandleCandidates(direction: LayoutDirection): string[] {
|
||||
return direction === "TB" ? [HANDLE_IDS.semanticOut] : [HANDLE_IDS.semanticOutBottom];
|
||||
}
|
||||
|
||||
export function optimizeModelInfraEdgeHandles(
|
||||
edges: Edge[],
|
||||
nodes: RecipeNode[],
|
||||
configs: Record<string, NodeConfig>,
|
||||
direction: LayoutDirection,
|
||||
): Edge[] {
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
const sourceUsage = new Map<string, number>();
|
||||
const targetUsage = new Map<string, number>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const sourceHandle = normalizeRecipeHandleId(edge.sourceHandle);
|
||||
const targetHandle = normalizeRecipeHandleId(edge.targetHandle);
|
||||
if (sourceHandle) {
|
||||
incrementUsage(sourceUsage, edge.source, sourceHandle);
|
||||
}
|
||||
if (targetHandle) {
|
||||
incrementUsage(targetUsage, edge.target, targetHandle);
|
||||
}
|
||||
}
|
||||
|
||||
const nextEdges: Edge[] = [];
|
||||
for (const edge of edges) {
|
||||
const source = configs[edge.source];
|
||||
const target = configs[edge.target];
|
||||
if (!(source && target)) {
|
||||
nextEdges.push(edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceHandleBefore = normalizeRecipeHandleId(edge.sourceHandle);
|
||||
const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle);
|
||||
const isModelSemantic =
|
||||
isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs);
|
||||
if (!isModelSemantic) {
|
||||
nextEdges.push(edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceHandleBefore) {
|
||||
decrementUsage(sourceUsage, edge.source, sourceHandleBefore);
|
||||
}
|
||||
if (targetHandleBefore) {
|
||||
decrementUsage(targetUsage, edge.target, targetHandleBefore);
|
||||
}
|
||||
|
||||
if (isProviderToConfigEdge(edge, configs)) {
|
||||
const sourceCandidates = getProviderSourceHandleCandidates(direction);
|
||||
const targetCandidates = getProviderTargetHandleCandidates(direction);
|
||||
const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
|
||||
const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
|
||||
nextEdges.push(
|
||||
applyEdgeWithHandles(
|
||||
edge,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
sourceUsage,
|
||||
targetUsage,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceCandidates = getConfigSourceHandleCandidates(direction);
|
||||
const targetCandidates = sortPreferredLlmTargetHandles(
|
||||
direction,
|
||||
nodesById.get(edge.source),
|
||||
nodesById.get(edge.target),
|
||||
);
|
||||
const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
|
||||
const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
|
||||
nextEdges.push(
|
||||
applyEdgeWithHandles(
|
||||
edge,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
sourceUsage,
|
||||
targetUsage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return nextEdges;
|
||||
}
|
||||
|
||||
export function centerModelInfraNodes(
|
||||
nodes: RecipeNode[],
|
||||
edges: Edge[],
|
||||
configs: Record<string, NodeConfig>,
|
||||
direction: LayoutDirection,
|
||||
): RecipeNode[] {
|
||||
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
const configToLlmIds = new Map<string, string[]>();
|
||||
const providerToConfigIds = new Map<string, string[]>();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (isProviderToConfigEdge(edge, configs)) {
|
||||
const entries = providerToConfigIds.get(edge.source) ?? [];
|
||||
if (!entries.includes(edge.target)) {
|
||||
entries.push(edge.target);
|
||||
}
|
||||
providerToConfigIds.set(edge.source, entries);
|
||||
continue;
|
||||
}
|
||||
if (isConfigToLlmEdge(edge, configs)) {
|
||||
const entries = configToLlmIds.get(edge.source) ?? [];
|
||||
if (!entries.includes(edge.target)) {
|
||||
entries.push(edge.target);
|
||||
}
|
||||
configToLlmIds.set(edge.source, entries);
|
||||
}
|
||||
}
|
||||
|
||||
const modelConfigIds = Object.values(configs)
|
||||
.filter((config) => config.kind === "model_config" && nodesById.has(config.id))
|
||||
.map((config) => config.id);
|
||||
const modelProviderIds = Object.values(configs)
|
||||
.filter((config) => config.kind === "model_provider" && nodesById.has(config.id))
|
||||
.map((config) => config.id);
|
||||
|
||||
const occupiedById = new Map(nodes.map((node) => [node.id, toRect(node)] as const));
|
||||
const clusterGap = 72;
|
||||
|
||||
const placeNode = (nodeId: string, preferred: XYPosition): void => {
|
||||
const currentNode = nodesById.get(nodeId);
|
||||
if (!currentNode) {
|
||||
return;
|
||||
}
|
||||
const width = readNodeWidth(currentNode) ?? DEFAULT_NODE_WIDTH;
|
||||
const height = readNodeHeight(currentNode) ?? DEFAULT_NODE_HEIGHT;
|
||||
occupiedById.delete(nodeId);
|
||||
const position = findNonOverlappingPosition(
|
||||
preferred,
|
||||
width,
|
||||
height,
|
||||
Array.from(occupiedById.values()),
|
||||
);
|
||||
const nextNode = { ...currentNode, position };
|
||||
nodesById.set(nodeId, nextNode);
|
||||
occupiedById.set(nodeId, {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
};
|
||||
|
||||
for (const modelConfigId of modelConfigIds) {
|
||||
const llmIds = configToLlmIds.get(modelConfigId) ?? [];
|
||||
const targetBounds = collectBounds(llmIds, nodesById);
|
||||
const modelConfigNode = nodesById.get(modelConfigId);
|
||||
if (!(targetBounds && modelConfigNode)) {
|
||||
continue;
|
||||
}
|
||||
const width = readNodeWidth(modelConfigNode) ?? DEFAULT_NODE_WIDTH;
|
||||
const height = readNodeHeight(modelConfigNode) ?? DEFAULT_NODE_HEIGHT;
|
||||
const preferred =
|
||||
direction === "LR"
|
||||
? {
|
||||
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
|
||||
y: targetBounds.minY - height - clusterGap,
|
||||
}
|
||||
: {
|
||||
x: targetBounds.minX - width - clusterGap,
|
||||
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
|
||||
};
|
||||
placeNode(modelConfigId, preferred);
|
||||
}
|
||||
|
||||
for (const modelProviderId of modelProviderIds) {
|
||||
const configIds = providerToConfigIds.get(modelProviderId) ?? [];
|
||||
const targetBounds = collectBounds(configIds, nodesById);
|
||||
const modelProviderNode = nodesById.get(modelProviderId);
|
||||
if (!(targetBounds && modelProviderNode)) {
|
||||
continue;
|
||||
}
|
||||
const width = readNodeWidth(modelProviderNode) ?? DEFAULT_NODE_WIDTH;
|
||||
const height = readNodeHeight(modelProviderNode) ?? DEFAULT_NODE_HEIGHT;
|
||||
const preferred =
|
||||
direction === "LR"
|
||||
? {
|
||||
x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
|
||||
y: targetBounds.minY - height - clusterGap,
|
||||
}
|
||||
: {
|
||||
x: targetBounds.minX - width - clusterGap,
|
||||
y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
|
||||
};
|
||||
placeNode(modelProviderId, preferred);
|
||||
}
|
||||
|
||||
return nodes.map((node) => nodesById.get(node.id) ?? node);
|
||||
}
|
||||
|
|
@ -26,9 +26,17 @@ import {
|
|||
} from "../blocks/registry";
|
||||
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
|
||||
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
|
||||
import { HANDLE_IDS, remapRecipeEdgeHandlesForLayout } from "../utils/handles";
|
||||
import {
|
||||
HANDLE_IDS,
|
||||
normalizeRecipeHandleId,
|
||||
remapRecipeEdgeHandlesForLayout,
|
||||
} from "../utils/handles";
|
||||
import type { RecipeSnapshot } from "../utils/import";
|
||||
import { getLayoutedElements } from "../utils/layout";
|
||||
import {
|
||||
centerModelInfraNodes,
|
||||
optimizeModelInfraEdgeHandles,
|
||||
} from "./helpers/model-infra-layout";
|
||||
import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals";
|
||||
import {
|
||||
applyRenameToConfigs,
|
||||
|
|
@ -206,6 +214,17 @@ function connectSemantic(
|
|||
};
|
||||
}
|
||||
|
||||
function isModelSemanticEdge(edge: Edge, configs: Record<string, NodeConfig>): boolean {
|
||||
const source = configs[edge.source];
|
||||
const target = configs[edge.target];
|
||||
return Boolean(
|
||||
source &&
|
||||
target &&
|
||||
((source.kind === "model_provider" && target.kind === "model_config") ||
|
||||
(source.kind === "model_config" && target.kind === "llm")),
|
||||
);
|
||||
}
|
||||
|
||||
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
||||
...INITIAL_STATE,
|
||||
setSheetView: (view) => set({ sheetView: view }),
|
||||
|
|
@ -217,10 +236,19 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
setLayoutDirection: (direction) =>
|
||||
set((state) => ({
|
||||
layoutDirection: direction,
|
||||
edges: state.edges.map((edge) => ({
|
||||
...edge,
|
||||
...remapRecipeEdgeHandlesForLayout(edge, direction),
|
||||
})),
|
||||
edges: state.edges.map((edge) => {
|
||||
if (isModelSemanticEdge(edge, state.configs)) {
|
||||
return {
|
||||
...edge,
|
||||
sourceHandle: normalizeRecipeHandleId(edge.sourceHandle),
|
||||
targetHandle: normalizeRecipeHandleId(edge.targetHandle),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
...remapRecipeEdgeHandlesForLayout(edge, direction),
|
||||
};
|
||||
}),
|
||||
nodes: applyLayoutDirectionToNodes(
|
||||
state.nodes,
|
||||
state.configs,
|
||||
|
|
@ -253,10 +281,23 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
}
|
||||
return { ...node, position };
|
||||
});
|
||||
const centeredNodes = centerModelInfraNodes(
|
||||
nextNodes,
|
||||
state.edges,
|
||||
state.configs,
|
||||
state.layoutDirection,
|
||||
);
|
||||
const optimizedEdges = optimizeModelInfraEdgeHandles(
|
||||
state.edges,
|
||||
centeredNodes,
|
||||
state.configs,
|
||||
state.layoutDirection,
|
||||
);
|
||||
return {
|
||||
auxNodePositions: {},
|
||||
edges: optimizedEdges,
|
||||
nodes: applyLayoutDirectionToNodes(
|
||||
nextNodes,
|
||||
centeredNodes,
|
||||
state.configs,
|
||||
state.layoutDirection,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { type Connection, type Edge, addEdge } from "@xyflow/react";
|
||||
import type { NodeConfig, SamplerConfig } from "../../types";
|
||||
import {
|
||||
HANDLE_IDS,
|
||||
isDataSourceHandle,
|
||||
isDataTargetHandle,
|
||||
isSemanticSourceHandle,
|
||||
isSemanticTargetHandle,
|
||||
normalizeRecipeHandleId,
|
||||
} from "../handles";
|
||||
import { isSemanticRelation } from "./relations";
|
||||
import {
|
||||
|
|
@ -127,6 +129,97 @@ function isCompetingIncomingEdge(
|
|||
return source.kind === "sampler" && source.sampler_type === "datetime";
|
||||
}
|
||||
|
||||
function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
|
||||
return (
|
||||
(source.kind === "model_provider" && target.kind === "model_config") ||
|
||||
(source.kind === "model_config" && target.kind === "llm")
|
||||
);
|
||||
}
|
||||
|
||||
function countHandleUsage(
|
||||
edges: Edge[],
|
||||
nodeId: string,
|
||||
handleId: string,
|
||||
lane: "source" | "target",
|
||||
): number {
|
||||
return edges.reduce((count, edge) => {
|
||||
const edgeNodeId = lane === "source" ? edge.source : edge.target;
|
||||
if (edgeNodeId !== nodeId) {
|
||||
return count;
|
||||
}
|
||||
const edgeHandleId =
|
||||
lane === "source"
|
||||
? normalizeRecipeHandleId(edge.sourceHandle)
|
||||
: normalizeRecipeHandleId(edge.targetHandle);
|
||||
return edgeHandleId === handleId ? count + 1 : count;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function pickLeastUsedHandle(
|
||||
candidates: string[],
|
||||
requested: string | null,
|
||||
usageFor: (handleId: string) => number,
|
||||
): string {
|
||||
let bestHandle = candidates[0];
|
||||
let bestCount = Number.POSITIVE_INFINITY;
|
||||
const requestedNormalized = requested
|
||||
? normalizeRecipeHandleId(requested)
|
||||
: null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const usage = usageFor(candidate);
|
||||
if (usage < bestCount) {
|
||||
bestHandle = candidate;
|
||||
bestCount = usage;
|
||||
continue;
|
||||
}
|
||||
if (usage === bestCount && requestedNormalized === candidate) {
|
||||
bestHandle = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestHandle;
|
||||
}
|
||||
|
||||
function chooseModelSemanticHandles(
|
||||
connection: Connection,
|
||||
source: NodeConfig,
|
||||
target: NodeConfig,
|
||||
edges: Edge[],
|
||||
): Connection {
|
||||
if (!isModelSemanticRelation(source, target)) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
const sourceCandidates = [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom];
|
||||
const targetCandidates =
|
||||
target.kind === "model_config"
|
||||
? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
|
||||
: [
|
||||
HANDLE_IDS.dataIn,
|
||||
HANDLE_IDS.dataInTop,
|
||||
HANDLE_IDS.dataInRight,
|
||||
HANDLE_IDS.dataInBottom,
|
||||
];
|
||||
|
||||
const sourceHandle = pickLeastUsedHandle(
|
||||
sourceCandidates,
|
||||
connection.sourceHandle ?? null,
|
||||
(handleId) => countHandleUsage(edges, source.id, handleId, "source"),
|
||||
);
|
||||
const targetHandle = pickLeastUsedHandle(
|
||||
targetCandidates,
|
||||
connection.targetHandle ?? null,
|
||||
(handleId) => countHandleUsage(edges, target.id, handleId, "target"),
|
||||
);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidRecipeConnection(
|
||||
connection: Connection,
|
||||
configs: Record<string, NodeConfig>,
|
||||
|
|
@ -177,8 +270,14 @@ export function applyRecipeConnection(
|
|||
!isCompetingIncomingEdge(edge, target.id, singleRefRelation, configs),
|
||||
)
|
||||
: edges;
|
||||
const resolvedConnection = chooseModelSemanticHandles(
|
||||
connection,
|
||||
source,
|
||||
target,
|
||||
nextBaseEdges,
|
||||
);
|
||||
const nextEdges = addEdge(
|
||||
{ ...connection, type: semanticRelation ? "semantic" : "canvas" },
|
||||
{ ...resolvedConnection, type: semanticRelation ? "semantic" : "canvas" },
|
||||
nextBaseEdges,
|
||||
);
|
||||
if (source.kind === "model_provider" && target.kind === "model_config") {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import dagre from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../constants";
|
||||
import type { LayoutDirection } from "../types";
|
||||
import { readNodeHeight, readNodeWidth } from "./rf-node-dimensions";
|
||||
|
||||
type LayoutOptions = {
|
||||
direction?: LayoutDirection;
|
||||
|
|
@ -21,8 +23,8 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
nodesep = 80,
|
||||
ranksep = 80,
|
||||
edgesep = 28,
|
||||
nodeWidth = 220,
|
||||
nodeHeight = 64,
|
||||
nodeWidth = DEFAULT_NODE_WIDTH,
|
||||
nodeHeight = DEFAULT_NODE_HEIGHT,
|
||||
} = options;
|
||||
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
|
|
@ -36,8 +38,8 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const width = node.measured?.width ?? nodeWidth;
|
||||
const height = node.measured?.height ?? nodeHeight;
|
||||
const width = readNodeWidth(node) ?? nodeWidth;
|
||||
const height = readNodeHeight(node) ?? nodeHeight;
|
||||
graph.setNode(node.id, { width, height });
|
||||
});
|
||||
|
||||
|
|
@ -54,8 +56,8 @@ export function getLayoutedElements<TNode extends Node>(
|
|||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const pos = graph.node(node.id);
|
||||
const width = node.measured?.width ?? nodeWidth;
|
||||
const height = node.measured?.height ?? nodeHeight;
|
||||
const width = readNodeWidth(node) ?? nodeWidth;
|
||||
const height = readNodeHeight(node) ?? nodeHeight;
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue