refactor(recipe-studio): simplify aux node graph logic and remove dead handle/sync code

This commit is contained in:
Shine1i 2026-02-26 10:43:26 +01:00
commit dd3e1e7293
14 changed files with 426 additions and 459 deletions

View file

@ -4,19 +4,15 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Handle,
NodeResizer,
Position,
type Node,
type NodeProps,
useUpdateNodeInternals,
} from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useRecipeStudioStore } from "../stores/recipe-studio";
import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
import {
AUX_HANDLE_CLASS,
getAuxSourceHandlePosition,
} from "../utils/handle-layout";
import type { LlmConfig, Score, ScoreOption } from "../types";
import { AUX_HANDLE_CLASS } from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariableEntries } from "../utils/variables";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
@ -28,14 +24,12 @@ type PromptInputNodeData = {
llmId: string;
field: PromptField;
title: string;
layoutDirection: LayoutDirection;
};
type JudgeScoreNodeData = {
kind: "llm-judge-score";
llmId: string;
scoreIndex: number;
layoutDirection: LayoutDirection;
};
export type RecipeGraphAuxNodeData = PromptInputNodeData | JudgeScoreNodeData;
@ -104,24 +98,47 @@ function AuxNodeBase({
return null;
}
const sourcePosition = getAuxSourceHandlePosition(data.layoutDirection);
const sourceHandles = (
<>
<Handle
id={HANDLE_IDS.llmInputOutLeft}
type="source"
position={Position.Left}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutRight}
type="source"
position={Position.Right}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutTop}
type="source"
position={Position.Top}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutBottom}
type="source"
position={Position.Bottom}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
</>
);
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={520}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
@ -137,14 +154,7 @@ function AuxNodeBase({
/>
<AuxVariableBadges llmId={data.llmId} />
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
{sourceHandles}
</BaseNode>
);
}
@ -190,18 +200,6 @@ function AuxNodeBase({
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={640}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
@ -260,14 +258,7 @@ function AuxNodeBase({
</Button>
</div>
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
{sourceHandles}
</BaseNode>
);
}

View file

@ -20,7 +20,6 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Handle,
NodeResizer,
Position,
useUpdateNodeInternals,
@ -32,12 +31,11 @@ import { useRecipeStudioStore } from "../stores/recipe-studio";
import type {
RecipeNode as RecipeGraphNodeType,
LlmType,
LayoutDirection,
NodeConfig,
SamplerType,
} from "../types";
import { NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
@ -268,89 +266,6 @@ function renderNodeBody(
return <p className="text-xs text-muted-foreground">{summary}</p>;
}
type LlmInputHandleItem = {
id: string;
label: string;
};
function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleItem[] {
if (!(config && config.kind === "llm")) {
return [];
}
const items: LlmInputHandleItem[] = [];
if (config.system_prompt.trim()) {
items.push({ id: HANDLE_IDS.llmSystemIn, label: "System" });
}
if (config.prompt.trim()) {
items.push({ id: HANDLE_IDS.llmPromptIn, label: "Prompt" });
}
if (config.llm_type === "judge") {
(config.scores ?? []).forEach((score, index) => {
items.push({
id: getLlmJudgeScoreHandleId(index),
label: score.name.trim() || `Score ${index + 1}`,
});
});
}
return items;
}
type LlmInputHandlesProps = {
items: LlmInputHandleItem[];
layoutDirection: LayoutDirection;
};
function LlmInputHandles({
items,
layoutDirection,
}: LlmInputHandlesProps): ReactElement | null {
if (items.length === 0) {
return null;
}
const isTopBottom = layoutDirection === "TB";
if (isTopBottom) {
return (
<div className="flex flex-wrap gap-2 pb-1">
{items.map((item) => (
<div
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%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
</div>
);
}
return (
<div className="space-y-1 pb-1">
{items.map((item) => (
<div key={item.id} className="pointer-events-none relative min-w-0 pl-3">
<Handle
id={item.id}
type="target"
position={Position.Left}
className={NODE_HANDLE_CLASS}
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="block truncate text-[10px] text-muted-foreground">
{item.label}
</span>
</div>
))}
</div>
);
}
function RecipeGraphNodeBase({
id,
data,
@ -418,7 +333,6 @@ function RecipeGraphNodeBase({
data.kind === "model_config" || data.kind === "model_provider";
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
const canShowLlmAux =
config?.kind === "llm" &&
(Boolean(config.prompt.trim()) ||
@ -491,7 +405,6 @@ function RecipeGraphNodeBase({
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<LlmInputHandles items={llmInputHandles} layoutDirection={layoutDirection} />
{nodeBody}
</BaseNodeContent>
@ -506,6 +419,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutLeft}
title="Data output"
type="source"
position={Position.Left}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInTop}
title="Data input"
@ -515,6 +437,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutTop}
title="Data output"
type="source"
position={Position.Top}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOut}
title="Data output"
@ -524,6 +455,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInRight}
title="Data input"
type="target"
position={Position.Right}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutBottom}
title="Data output"
@ -533,6 +473,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInBottom}
title="Data input"
type="target"
position={Position.Bottom}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
</>
)}

View file

@ -94,7 +94,6 @@ export function RecipeStudioPage({
nodes,
edges,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
configs,
processors,
@ -125,15 +124,11 @@ export function RecipeStudioPage({
setLayoutDirection,
applyLayout,
setAuxNodePosition,
setAuxNodeSize,
syncAuxNodePositions,
syncAuxNodeSizes,
} = useRecipeStudioStore(
useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
llmAuxVisibility: state.llmAuxVisibility,
configs: state.configs,
processors: state.processors,
@ -164,9 +159,6 @@ export function RecipeStudioPage({
setLayoutDirection: state.setLayoutDirection,
applyLayout: state.applyLayout,
setAuxNodePosition: state.setAuxNodePosition,
setAuxNodeSize: state.setAuxNodeSize,
syncAuxNodePositions: state.syncAuxNodePositions,
syncAuxNodeSizes: state.syncAuxNodeSizes,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
@ -202,12 +194,10 @@ export function RecipeStudioPage({
configs,
layoutDirection,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
});
}, [
auxNodePositions,
auxNodeSizes,
configs,
edges,
layoutDirection,
@ -218,12 +208,6 @@ export function RecipeStudioPage({
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
);
useEffect(() => {
syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults);
}, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]);
useEffect(() => {
syncAuxNodeSizes(displayGraph.auxNodeIds);
}, [displayGraph.auxNodeIds, syncAuxNodeSizes]);
const handleNodeClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
@ -250,7 +234,7 @@ export function RecipeStudioPage({
const handleNodesChange = useCallback(
(changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
applyAuxNodeChanges(changes, { setAuxNodePosition });
const next = filterNodeChangesByIds(
changes as NodeChange<RecipeBuilderNode>[],
baseNodeIds,
@ -259,7 +243,7 @@ export function RecipeStudioPage({
onNodesChange(next);
}
},
[baseNodeIds, onNodesChange, setAuxNodePosition, setAuxNodeSize],
[baseNodeIds, onNodesChange, setAuxNodePosition],
);
const handleEdgesChange = useCallback(
@ -288,8 +272,16 @@ export function RecipeStudioPage({
}, []);
const payloadResult = useMemo(
() => buildRecipePayload(configs, nodes, edges, processors, layoutDirection),
[configs, edges, layoutDirection, nodes, processors],
() =>
buildRecipePayload(
configs,
nodes,
edges,
processors,
layoutDirection,
auxNodePositions,
),
[auxNodePositions, configs, edges, layoutDirection, nodes, processors],
);
const getCurrentPayloadFromStore = useCallback((): RecipePayload => {
const state = useRecipeStudioStore.getState();
@ -299,6 +291,7 @@ export function RecipeStudioPage({
state.edges,
state.processors,
state.layoutDirection,
state.auxNodePositions,
).payload;
}, []);
const {

View file

@ -1,61 +0,0 @@
import type { XYPosition } from "@xyflow/react";
export function syncPositionsRecord(
prev: Record<string, XYPosition>,
activeIds: string[],
defaults: Record<string, XYPosition>,
): Record<string, XYPosition> {
const next: Record<string, XYPosition> = {};
for (const id of activeIds) {
const existing = prev[id];
if (existing) {
next[id] = existing;
continue;
}
const fallback = defaults[id];
if (fallback) {
next[id] = fallback;
}
}
const prevIds = Object.keys(prev);
const nextIds = Object.keys(next);
if (prevIds.length !== nextIds.length) {
return next;
}
for (const id of nextIds) {
const a = prev[id];
const b = next[id];
if (!(a && b && a.x === b.x && a.y === b.y)) {
return next;
}
}
return prev;
}
export function syncSizesRecord(
prev: Record<string, { width: number; height: number }>,
activeIds: string[],
): Record<string, { width: number; height: number }> {
const active = new Set(activeIds);
const next: Record<string, { width: number; height: number }> = {};
for (const [id, size] of Object.entries(prev)) {
if (active.has(id)) {
next[id] = size;
}
}
const prevIds = Object.keys(prev);
const nextIds = Object.keys(next);
if (prevIds.length !== nextIds.length) {
return next;
}
for (const id of nextIds) {
const a = prev[id];
const b = next[id];
if (!(a && b && a.width === b.width && a.height === b.height)) {
return next;
}
}
return prev;
}

View file

@ -29,7 +29,6 @@ import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
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";
import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals";
import {
applyRenameToConfigs,
@ -53,7 +52,6 @@ type RecipeStudioState = {
nodes: RecipeNode[];
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
configs: Record<string, NodeConfig>;
processors: RecipeProcessorConfig[];
@ -83,15 +81,6 @@ type RecipeStudioState = {
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
setAuxNodeSize: (
id: string,
size: { width: number; height: number },
) => void;
syncAuxNodePositions: (
activeIds: string[],
defaults: Record<string, XYPosition>,
) => void;
syncAuxNodeSizes: (activeIds: string[]) => void;
onNodesChange: (changes: NodeChange<RecipeNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
@ -102,7 +91,6 @@ const INITIAL_STATE = {
nodes: [],
edges: [],
auxNodePositions: {},
auxNodeSizes: {},
llmAuxVisibility: {},
configs: {},
processors: [],
@ -118,7 +106,6 @@ const INITIAL_STATE = {
| "nodes"
| "edges"
| "auxNodePositions"
| "auxNodeSizes"
| "llmAuxVisibility"
| "configs"
| "processors"
@ -248,8 +235,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
edges: state.edges,
configs: state.configs,
layoutDirection: state.layoutDirection,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
auxNodePositions: {},
llmAuxVisibility: state.llmAuxVisibility,
});
const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
@ -267,25 +253,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
}
return { ...node, position };
});
const nextAuxNodePositions: Record<string, XYPosition> = {};
for (const auxId of displayGraph.auxNodeIds) {
const existing = state.auxNodePositions[auxId];
const layouted = layoutedPositions.get(auxId);
if (layouted) {
nextAuxNodePositions[auxId] = layouted;
continue;
}
if (existing) {
nextAuxNodePositions[auxId] = existing;
continue;
}
const fallback = displayGraph.auxDefaults[auxId];
if (fallback) {
nextAuxNodePositions[auxId] = fallback;
}
}
return {
auxNodePositions: nextAuxNodePositions,
auxNodePositions: {},
nodes: applyLayoutDirectionToNodes(
nextNodes,
state.configs,
@ -461,8 +430,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
layoutDirection: snapshot.layoutDirection,
nextId: snapshot.nextId,
nextY: snapshot.nextY,
auxNodePositions: {},
auxNodeSizes: {},
auxNodePositions: snapshot.auxNodePositions ?? {},
llmAuxVisibility: {},
activeConfigId: null,
dialogOpen: false,
@ -482,36 +450,6 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
},
};
}),
setAuxNodeSize: (id, size) =>
set((state) => {
const width = Math.max(1, size.width);
const height = Math.max(1, size.height);
const current = state.auxNodeSizes[id];
if (current && current.width === width && current.height === height) {
return state;
}
return {
auxNodeSizes: {
...state.auxNodeSizes,
[id]: { width, height },
},
};
}),
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults);
if (next === state.auxNodePositions) {
return state;
}
return {
auxNodePositions: next,
};
}),
syncAuxNodeSizes: (activeIds) =>
set((state) => {
const next = syncSizesRecord(state.auxNodeSizes, activeIds);
return next === state.auxNodeSizes ? state : { auxNodeSizes: next };
}),
updateConfig: (id, patch) => {
const applyUpdate = (state: RecipeStudioState) => {
const current = state.configs[id];

View file

@ -7,7 +7,6 @@ import {
getDefaultDataTargetHandle,
getDefaultSemanticSourceHandle,
getDefaultSemanticTargetHandle,
getLlmJudgeScoreHandleId,
HANDLE_IDS,
isDataSourceHandle,
isDataTargetHandle,
@ -24,15 +23,12 @@ type DisplayGraphInput = {
configs: Record<string, NodeConfig>;
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
};
export type DisplayGraph = {
nodes: Array<Node<RecipeNode["data"] | RecipeGraphAuxNodeData>>;
edges: Edge[];
auxNodeIds: string[];
auxDefaults: Record<string, XYPosition>;
};
function normalizeEdge(
@ -99,7 +95,6 @@ function normalizeEdge(
type AuxNodeItem = {
key: string;
targetHandle: string;
data: RecipeGraphAuxNodeData;
};
@ -136,42 +131,235 @@ function findNonOverlappingPosition(
preferred: XYPosition,
width: number,
height: number,
direction: LayoutDirection,
occupied: Rect[],
): XYPosition {
const primaryStep =
direction === "TB"
? { x: 0, y: -(height + 24) }
: { x: -(width + 24), y: 0 };
const lateralUnit =
direction === "TB"
? { x: Math.max(48, Math.round(width * 0.3)), y: 0 }
: { x: 0, y: Math.max(40, Math.round(height * 0.35)) };
const lateralPattern = [0, 1, -1, 2, -2];
for (let ring = 0; ring <= 8; ring += 1) {
for (const lateral of lateralPattern) {
const candidate = {
x: preferred.x + primaryStep.x * ring + lateralUnit.x * lateral,
y: preferred.y + primaryStep.y * ring + lateralUnit.y * lateral,
};
const rect = toRect(candidate, width, height);
if (!occupied.some((other) => intersects(rect, other))) {
return candidate;
const step = 24;
for (let ring = 0; ring <= 10; 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 = toRect(candidate, width, height);
if (!occupied.some((other) => intersects(rect, other))) {
return candidate;
}
}
}
}
return preferred;
}
type HandleSide = "left" | "right" | "top" | "bottom";
const SIDE_TO_TARGET_HANDLE: Record<HandleSide, string> = {
left: HANDLE_IDS.dataIn,
right: HANDLE_IDS.dataInRight,
top: HANDLE_IDS.dataInTop,
bottom: HANDLE_IDS.dataInBottom,
};
function getTargetSide(
handleId: string | null | undefined,
direction: LayoutDirection,
): HandleSide {
const normalized = normalizeRecipeHandleId(handleId);
if (!normalized) {
return direction === "TB" ? "top" : "left";
}
if (
normalized === HANDLE_IDS.dataInRight ||
normalized === HANDLE_IDS.semanticInRight
) {
return "right";
}
if (
normalized === HANDLE_IDS.dataInBottom ||
normalized === HANDLE_IDS.semanticInBottom
) {
return "bottom";
}
if (
normalized === HANDLE_IDS.dataInTop ||
normalized === HANDLE_IDS.semanticInTop
) {
return "top";
}
return "left";
}
function getSourceSide(
handleId: string | null | undefined,
direction: LayoutDirection,
): HandleSide {
const normalized = normalizeRecipeHandleId(handleId);
if (!normalized) {
return direction === "TB" ? "bottom" : "right";
}
if (
normalized === HANDLE_IDS.dataOutLeft ||
normalized === HANDLE_IDS.semanticOutLeft
) {
return "left";
}
if (
normalized === HANDLE_IDS.dataOutTop ||
normalized === HANDLE_IDS.semanticOutTop
) {
return "top";
}
if (
normalized === HANDLE_IDS.dataOutBottom ||
normalized === HANDLE_IDS.semanticOutBottom
) {
return "bottom";
}
return "right";
}
function pickAuxTargetHandle(
llmId: string,
direction: LayoutDirection,
edges: Edge[],
): string {
const occupied = new Set<HandleSide>();
for (const edge of edges) {
if (edge.source.startsWith("aux-") || edge.target.startsWith("aux-")) {
continue;
}
if (edge.target === llmId) {
occupied.add(getTargetSide(edge.targetHandle, direction));
}
if (edge.source === llmId) {
occupied.add(getSourceSide(edge.sourceHandle, direction));
}
}
const priority: HandleSide[] =
direction === "LR"
? ["left", "right", "bottom", "top"]
: ["top", "bottom", "right", "left"];
for (const side of priority) {
if (!occupied.has(side)) {
return SIDE_TO_TARGET_HANDLE[side];
}
}
const fallback: HandleSide = direction === "LR" ? "bottom" : "right";
return SIDE_TO_TARGET_HANDLE[fallback];
}
function getHandleSideFromTargetHandle(targetHandle: string): HandleSide {
if (targetHandle === HANDLE_IDS.dataInRight) {
return "right";
}
if (targetHandle === HANDLE_IDS.dataInTop) {
return "top";
}
if (targetHandle === HANDLE_IDS.dataInBottom) {
return "bottom";
}
return "left";
}
function pickAuxSourceHandle(
auxPosition: XYPosition,
auxWidth: number,
auxHeight: number,
llmPosition: XYPosition,
llmWidth: number,
llmHeight: number,
): string {
const auxCenter = {
x: auxPosition.x + auxWidth / 2,
y: auxPosition.y + auxHeight / 2,
};
const llmCenter = {
x: llmPosition.x + llmWidth / 2,
y: llmPosition.y + llmHeight / 2,
};
const dx = llmCenter.x - auxCenter.x;
const dy = llmCenter.y - auxCenter.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0 ? HANDLE_IDS.llmInputOutRight : HANDLE_IDS.llmInputOutLeft;
}
return dy >= 0 ? HANDLE_IDS.llmInputOutBottom : HANDLE_IDS.llmInputOutTop;
}
type AppendAuxNodeAndEdgeInput = {
auxNodes: Node<RecipeGraphAuxNodeData>[];
auxEdges: Edge[];
entry: {
item: AuxNodeItem;
auxId: string;
width: number;
height: number;
};
position: XYPosition;
parentNode: Node<RecipeNode["data"] | RecipeGraphAuxNodeData>;
parentWidth: number;
parentHeight: number;
auxTargetHandle: string;
};
function appendAuxNodeAndEdge({
auxNodes,
auxEdges,
entry,
position,
parentNode,
parentWidth,
parentHeight,
auxTargetHandle,
}: AppendAuxNodeAndEdgeInput): void {
auxNodes.push({
id: entry.auxId,
type: "aux",
data: entry.item.data,
position,
width: entry.width,
height: entry.height,
style: {
width: entry.width,
height: entry.height,
},
draggable: true,
selectable: true,
focusable: true,
connectable: false,
});
auxEdges.push({
id: `e-${entry.auxId}-${parentNode.id}`,
source: entry.auxId,
sourceHandle: pickAuxSourceHandle(
position,
entry.width,
entry.height,
parentNode.position,
parentWidth,
parentHeight,
),
target: parentNode.id,
targetHandle: auxTargetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
});
}
export function deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
}: DisplayGraphInput): DisplayGraph {
const displayNodes = nodes.map((node) => {
@ -190,8 +378,6 @@ export function deriveDisplayGraph({
});
const auxNodes: Node<RecipeGraphAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
const occupiedRects: Rect[] = displayNodes.map((node) =>
toRect(
node.position,
@ -209,18 +395,18 @@ export function deriveDisplayGraph({
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const auxTargetHandle = pickAuxTargetHandle(node.id, llmDirection, edges);
const auxTargetSide = getHandleSideFromTargetHandle(auxTargetHandle);
const items: AuxNodeItem[] = [];
if (config.system_prompt.trim()) {
items.push({
key: "system",
targetHandle: HANDLE_IDS.llmSystemIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "system_prompt",
title: "System Prompt",
layoutDirection: llmDirection,
},
});
}
@ -228,13 +414,11 @@ export function deriveDisplayGraph({
if (config.prompt.trim()) {
items.push({
key: "prompt",
targetHandle: HANDLE_IDS.llmPromptIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "prompt",
title: "Prompt",
layoutDirection: llmDirection,
},
});
}
@ -243,12 +427,10 @@ export function deriveDisplayGraph({
(config.scores ?? []).forEach((_score, scoreIndex) => {
items.push({
key: `score-${scoreIndex}`,
targetHandle: getLlmJudgeScoreHandleId(scoreIndex),
data: {
kind: "llm-judge-score",
llmId: config.id,
scoreIndex,
layoutDirection: llmDirection,
},
});
});
@ -262,19 +444,20 @@ export function deriveDisplayGraph({
const parentHeight = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT;
const itemsWithLayout = items.map((item) => {
const auxId = `aux-${node.id}-${item.key}`;
const savedSize = auxNodeSizes[auxId];
return {
item,
auxId,
width: savedSize?.width ?? DEFAULT_NODE_WIDTH,
height: savedSize?.height ?? DEFAULT_NODE_HEIGHT,
width: DEFAULT_NODE_WIDTH,
height: DEFAULT_NODE_HEIGHT,
};
});
const gap = 24;
const sideOffset = 48;
const stackHorizontal =
auxTargetSide === "top" || auxTargetSide === "bottom";
if (llmDirection === "TB") {
if (stackHorizontal) {
const totalWidth =
itemsWithLayout.reduce((sum, entry) => sum + entry.width, 0) +
(itemsWithLayout.length - 1) * gap;
@ -284,51 +467,30 @@ export function deriveDisplayGraph({
for (const entry of itemsWithLayout) {
const preferredPosition = {
x: xCursor,
y: node.position.y - entry.height - sideOffset,
y:
auxTargetSide === "top"
? node.position.y - entry.height - sideOffset
: node.position.y + parentHeight + sideOffset,
};
const defaultPosition = findNonOverlappingPosition(
preferredPosition,
entry.width,
entry.height,
llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
xCursor += entry.width + gap;
auxNodeIds.push(entry.auxId);
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,
type: "aux",
data: entry.item.data,
appendAuxNodeAndEdge({
auxNodes,
auxEdges,
entry,
position,
width: entry.width,
height: entry.height,
style: {
width: entry.width,
height: entry.height,
},
draggable: true,
selectable: true,
focusable: true,
connectable: true,
});
auxEdges.push({
id: `e-${entry.auxId}-${node.id}`,
source: entry.auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: entry.item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
parentNode: node,
parentWidth,
parentHeight,
auxTargetHandle,
});
}
continue;
@ -338,7 +500,10 @@ export function deriveDisplayGraph({
itemsWithLayout.reduce((sum, entry) => sum + entry.height, 0) +
(itemsWithLayout.length - 1) * gap;
const maxWidth = Math.max(...itemsWithLayout.map((entry) => entry.width));
const baseX = node.position.x - maxWidth - sideOffset;
const baseX =
auxTargetSide === "right"
? node.position.x + parentWidth + sideOffset
: node.position.x - maxWidth - sideOffset;
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
for (const entry of itemsWithLayout) {
@ -350,45 +515,21 @@ export function deriveDisplayGraph({
preferredPosition,
entry.width,
entry.height,
llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
yCursor += entry.height + gap;
auxNodeIds.push(entry.auxId);
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,
type: "aux",
data: entry.item.data,
appendAuxNodeAndEdge({
auxNodes,
auxEdges,
entry,
position,
width: entry.width,
height: entry.height,
style: {
width: entry.width,
height: entry.height,
},
draggable: true,
selectable: true,
focusable: true,
connectable: true,
});
auxEdges.push({
id: `e-${entry.auxId}-${node.id}`,
source: entry.auxId,
sourceHandle: HANDLE_IDS.llmInputOut,
target: node.id,
targetHandle: entry.item.targetHandle,
type: "canvas",
data: { path: "auto" },
selectable: false,
focusable: false,
parentNode: node,
parentWidth,
parentHeight,
auxTargetHandle,
});
}
}
@ -398,7 +539,5 @@ export function deriveDisplayGraph({
edges: [...edges, ...auxEdges].map((edge) =>
normalizeEdge(edge, configs, layoutDirection),
),
auxNodeIds,
auxDefaults,
};
}

View file

@ -1,36 +1,5 @@
import { Position } from "@xyflow/react";
import type { LayoutDirection } from "../types";
export const NODE_HANDLE_CLASS =
"pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20";
export const AUX_HANDLE_CLASS =
"!size-2 !border-border/80 !bg-muted/80 shadow-sm";
export type NodeHandleLayout = {
isTopBottom: boolean;
dataInPosition: Position;
dataOutPosition: Position;
semanticInPosition: Position;
semanticOutPosition: Position;
};
export function getNodeHandleLayout(
direction: LayoutDirection,
): NodeHandleLayout {
const isTopBottom = direction === "TB";
return {
isTopBottom,
dataInPosition: isTopBottom ? Position.Top : Position.Left,
dataOutPosition: isTopBottom ? Position.Bottom : Position.Right,
semanticInPosition: isTopBottom ? Position.Left : Position.Top,
semanticOutPosition: isTopBottom ? Position.Right : Position.Bottom,
};
}
export function getAuxSourceHandlePosition(
direction: LayoutDirection,
): Position {
return direction === "TB" ? Position.Bottom : Position.Right;
}

View file

@ -23,17 +23,14 @@ export const HANDLE_IDS = {
semanticOutBottom: "semantic-out-bottom",
semanticOutRight: "semantic-out-right",
// llm prompt/scorer lanes
llmPromptIn: "llm-prompt-in",
llmSystemIn: "llm-system-in",
llmInputOut: "llm-input-out",
llmInputOutLeft: "llm-input-out-left",
llmInputOutRight: "llm-input-out-right",
llmInputOutTop: "llm-input-out-top",
llmInputOutBottom: "llm-input-out-bottom",
} as const;
export type RecipeHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS];
export function getLlmJudgeScoreHandleId(index: number): string {
return `llm-judge-score-in-${index}`;
}
const LEGACY_HANDLE_ALIAS_MAP: Record<string, string> = {
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,

View file

@ -453,7 +453,7 @@ export function importRecipePayload(input: string): ImportResult {
return { errors, snapshot: null };
}
const { layouts, edges: uiEdges, layoutDirection } = parseUi(ui);
const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui);
const resolvedLayoutDirection = layoutDirection ?? "LR";
const nodes = buildNodes(configs, layouts);
const edges = buildEdges(
@ -462,6 +462,15 @@ export function importRecipePayload(input: string): ImportResult {
uiEdges,
resolvedLayoutDirection,
);
const auxNodePositions = Object.fromEntries(
auxNodes.flatMap((item) => {
const llmId = nameToId.get(item.llm);
if (!llmId) {
return [];
}
return [[`aux-${llmId}-${item.key}`, { x: item.x, y: item.y }]];
}),
);
const maxY = nodes.reduce(
(acc, node) => Math.max(acc, node.position.y),
@ -474,6 +483,7 @@ export function importRecipePayload(input: string): ImportResult {
configs: Object.fromEntries(configs.map((config) => [config.id, config])),
nodes,
edges,
auxNodePositions,
processors,
layoutDirection: resolvedLayoutDirection,
nextId,

View file

@ -1,4 +1,4 @@
import type { Edge } from "@xyflow/react";
import type { Edge, XYPosition } from "@xyflow/react";
import type {
LayoutDirection,
RecipeNode,
@ -10,6 +10,7 @@ export type RecipeSnapshot = {
configs: Record<string, NodeConfig>;
nodes: RecipeNode[];
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
processors: RecipeProcessorConfig[];
layoutDirection: LayoutDirection;
nextId: number;

View file

@ -7,14 +7,23 @@ import { isRecord, readString } from "./helpers";
type UiInput = {
nodes?: unknown;
edges?: unknown;
aux_nodes?: unknown;
layout_direction?: unknown;
layoutDirection?: unknown;
};
type ParsedAuxNode = {
llm: string;
key: string;
x: number;
y: number;
};
export function parseUi(
ui: UiInput | null,
): {
layouts: Map<string, { x: number; y: number; width?: number }>;
auxNodes: ParsedAuxNode[];
edges: Array<{
from: string;
to: string;
@ -25,6 +34,7 @@ export function parseUi(
layoutDirection: "LR" | "TB" | null;
} {
const layouts = new Map<string, { x: number; y: number; width?: number }>();
const auxNodes: ParsedAuxNode[] = [];
const edges: Array<{
from: string;
to: string;
@ -72,6 +82,21 @@ export function parseUi(
}
}
}
if (ui && Array.isArray(ui.aux_nodes)) {
for (const node of ui.aux_nodes) {
if (!isRecord(node)) {
continue;
}
const llm = readString(node.llm);
const key = readString(node.key);
const x = typeof node.x === "number" ? node.x : null;
const y = typeof node.y === "number" ? node.y : null;
if (!(llm && key && x !== null && y !== null)) {
continue;
}
auxNodes.push({ llm, key, x, y });
}
}
const layoutDirectionRaw =
readString(ui?.layout_direction) ?? readString(ui?.layoutDirection);
const layoutDirection =
@ -81,7 +106,12 @@ export function parseUi(
? "LR"
: null;
return { layouts, edges: edges.length > 0 ? edges : null, layoutDirection };
return {
layouts,
auxNodes,
edges: edges.length > 0 ? edges : null,
layoutDirection,
};
}
export function buildNodes(

View file

@ -1,4 +1,4 @@
import type { Edge } from "@xyflow/react";
import type { Edge, XYPosition } from "@xyflow/react";
import type {
LayoutDirection,
ModelConfig,
@ -70,6 +70,7 @@ export function buildRecipePayload(
edges: Edge[],
processors: RecipeProcessorConfig[] = [],
layoutDirection: LayoutDirection = "LR",
auxNodePositions: Record<string, XYPosition> = {},
): RecipePayloadResult {
const errors: string[] = [];
const columns: Record<string, unknown>[] = [];
@ -270,6 +271,27 @@ export function buildRecipePayload(
},
];
});
const uiAuxNodes = Object.entries(auxNodePositions).flatMap(
([auxId, position]) => {
const match = /^aux-([^-]+)-(.+)$/.exec(auxId);
if (!match) {
return [];
}
const [, llmId, key] = match;
const llmConfig = configs[llmId];
if (!(llmConfig && llmConfig.kind === "llm")) {
return [];
}
return [
{
llm: llmConfig.name,
key,
x: position.x,
y: position.y,
},
];
},
);
const recipeProcessors = buildProcessors(processors, errors);
const seedConfig = firstSeed ? buildSeedConfig(firstSeed, errors) : undefined;
const seedDropProcessor = firstSeed
@ -306,6 +328,7 @@ export function buildRecipePayload(
nodes: uiNodes,
edges: uiEdges,
layout_direction: layoutDirection,
...(uiAuxNodes.length > 0 && { aux_nodes: uiAuxNodes }),
...(firstSeed && { seed_source_type: firstSeed.seed_source_type }),
...(firstSeed && { seed_columns: firstSeed.seed_columns ?? [] }),
...(firstSeed && {

View file

@ -52,6 +52,13 @@ export type RecipePayload = {
layout_direction?: "LR" | "TB";
// ui-only, used to preserve seed block mode across imports/refresh
seed_source_type?: "hf" | "local" | "unstructured";
// ui-only, persisted aux node positions by llm name + aux key
aux_nodes?: Array<{
llm: string;
key: string;
x: number;
y: number;
}>;
// ui-only, seed metadata cached for refresh/import UX
seed_columns?: string[];
seed_drop_columns?: string[];

View file

@ -5,43 +5,25 @@ import type {
NodeChange,
XYPosition,
} from "@xyflow/react";
import type { RecipeGraphAuxNodeData } from "../components/recipe-graph-aux-node";
import type { RecipeNodeData } from "../types";
type AnyNode = Node<RecipeNodeData | RecipeGraphAuxNodeData>;
export function applyAuxNodeChanges(
changes: NodeChange<AnyNode>[],
export function applyAuxNodeChanges<T extends Node>(
changes: NodeChange<T>[],
actions: {
setAuxNodePosition: (id: string, position: XYPosition) => void;
setAuxNodeSize: (
id: string,
size: { width: number; height: number },
) => void;
},
): void {
for (const change of changes) {
if (!("id" in change) || !change.id.startsWith("aux-")) {
continue;
}
if (change.type === "position") {
const nextPosition = change.position ?? change.positionAbsolute;
if (nextPosition) {
actions.setAuxNodePosition(change.id, nextPosition);
}
if (change.type !== "position") {
continue;
}
if (
change.type === "dimensions" &&
change.dimensions &&
change.dimensions.width > 0 &&
change.dimensions.height > 0
) {
actions.setAuxNodeSize(change.id, {
width: change.dimensions.width,
height: change.dimensions.height,
});
const nextPosition = change.position ?? change.positionAbsolute;
if (!nextPosition) {
continue;
}
actions.setAuxNodePosition(change.id, nextPosition);
}
}
@ -62,4 +44,3 @@ export function filterEdgeChangesByIds(
(change): change is EdgeChange<Edge> => "id" in change && ids.has(change.id),
);
}