refactor: improve layout direction handling and auxiliary node visibility for LLMS

This commit is contained in:
Shine1i 2026-02-22 05:18:33 +01:00
commit 17a22fe155
8 changed files with 431 additions and 89 deletions

View file

@ -5,7 +5,6 @@ import { Textarea } from "@/components/ui/textarea";
import {
Handle,
NodeResizer,
Position,
type Node,
type NodeProps,
useUpdateNodeInternals,
@ -14,6 +13,10 @@ 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 { HANDLE_IDS } from "../utils/handles";
import { getAvailableVariableEntries } from "../utils/variables";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
@ -101,8 +104,7 @@ function AuxNodeBase({
return null;
}
const sourcePosition =
data.layoutDirection === "TB" ? Position.Bottom : Position.Right;
const sourcePosition = getAuxSourceHandlePosition(data.layoutDirection);
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
@ -141,7 +143,7 @@ function AuxNodeBase({
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className="!size-2 !border-border !bg-background"
className={AUX_HANDLE_CLASS}
/>
</BaseNode>
);
@ -264,7 +266,7 @@ function AuxNodeBase({
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className="!size-2 !border-border !bg-background"
className={AUX_HANDLE_CLASS}
/>
</BaseNode>
);

View file

@ -31,9 +31,11 @@ import { useRecipeStudioStore } from "../stores/recipe-studio";
import type {
RecipeNode as RecipeGraphNodeType,
LlmType,
LayoutDirection,
NodeConfig,
SamplerType,
} from "../types";
import { getNodeHandleLayout, NODE_HANDLE_CLASS } from "../utils/handle-layout";
import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
@ -240,13 +242,17 @@ function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleI
type LlmInputHandlesProps = {
items: LlmInputHandleItem[];
isTopBottom: boolean;
layoutDirection: LayoutDirection;
};
function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactElement | null {
function LlmInputHandles({
items,
layoutDirection,
}: LlmInputHandlesProps): ReactElement | null {
if (items.length === 0) {
return null;
}
const isTopBottom = layoutDirection === "TB";
if (isTopBottom) {
return (
@ -260,7 +266,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
id={item.id}
type="target"
position={Position.Top}
className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
className={NODE_HANDLE_CLASS}
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
@ -278,7 +284,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
id={item.id}
type="target"
position={Position.Left}
className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
className={NODE_HANDLE_CLASS}
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="block truncate text-[10px] text-muted-foreground">
@ -301,6 +307,12 @@ function RecipeGraphNodeBase({
const config = useRecipeStudioStore((state) => state.configs[id]);
const openConfig = useRecipeStudioStore((state) => state.openConfig);
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
const llmAuxVisible = useRecipeStudioStore(
(state) => state.llmAuxVisibility[id] ?? false,
);
const setLlmAuxVisibility = useRecipeStudioStore(
(state) => state.setLlmAuxVisibility,
);
const updateNodeInternals = useUpdateNodeInternals();
useEffect(() => {
@ -313,16 +325,21 @@ function RecipeGraphNodeBase({
data.kind === "sampler";
const showSemanticIn = data.kind === "llm" || data.kind === "model_config";
const showSemanticOut = data.kind === "model_config" || data.kind === "model_provider";
const isTopBottom = layoutDirection === "TB";
const dataInPosition = isTopBottom ? Position.Top : Position.Left;
const dataOutPosition = isTopBottom ? Position.Bottom : Position.Right;
const semanticInPosition = isTopBottom ? Position.Left : Position.Top;
const semanticOutPosition = isTopBottom ? Position.Right : Position.Bottom;
const {
dataInPosition,
dataOutPosition,
semanticInPosition,
semanticOutPosition,
} = getNodeHandleLayout(layoutDirection);
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
const llmInputHandles = getLlmInputHandleItems(config);
const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
const canShowLlmAux =
config?.kind === "llm" &&
(Boolean(config.prompt.trim()) ||
Boolean(config.system_prompt.trim()) ||
Boolean((config.scores?.length ?? 0) > 0));
return (
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
@ -357,23 +374,40 @@ function RecipeGraphNodeBase({
</p>
</div>
</div>
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openConfig(id);
}}
>
Configure
</Button>
<div className="flex items-center gap-1">
{canShowLlmAux && (
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setLlmAuxVisibility(id, !llmAuxVisible);
}}
>
{llmAuxVisible ? "Hide inputs" : "Show inputs"}
</Button>
)}
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openConfig(id);
}}
>
Configure
</Button>
</div>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<LlmInputHandles items={llmInputHandles} isTopBottom={isTopBottom} />
<LlmInputHandles items={llmInputHandles} layoutDirection={layoutDirection} />
{nodeBody}
</BaseNodeContent>
@ -386,7 +420,7 @@ function RecipeGraphNodeBase({
position={dataInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOut}
@ -395,7 +429,7 @@ function RecipeGraphNodeBase({
position={dataOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
handleClassName={NODE_HANDLE_CLASS}
/>
</>
)}
@ -408,7 +442,7 @@ function RecipeGraphNodeBase({
position={semanticInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
handleClassName={NODE_HANDLE_CLASS}
/>
)}
@ -420,7 +454,7 @@ function RecipeGraphNodeBase({
position={semanticOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
handleClassName={NODE_HANDLE_CLASS}
/>
)}
</BaseNode>

View file

@ -28,12 +28,6 @@ export function DataEdge({
}: EdgeProps<DataEdge>): ReactElement {
const resolvedPathType = resolvePathType({
type: data.path ?? "auto",
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const [edgePath] = getPath({
type: resolvedPathType,
@ -117,44 +111,11 @@ function getPath({
function resolvePathType({
type,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: {
type: "auto" | "bezier" | "smoothstep" | "step" | "straight";
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
sourcePosition: Position;
targetPosition: Position;
}): "bezier" | "smoothstep" | "step" | "straight" {
if (type !== "auto") {
return type;
}
const isVerticalFlow =
(sourcePosition === Position.Bottom && targetPosition === Position.Top) ||
(sourcePosition === Position.Top && targetPosition === Position.Bottom);
if (isVerticalFlow && Math.abs(sourceX - targetX) <= 18) {
return "straight";
}
const isHorizontalFlow =
(sourcePosition === Position.Right && targetPosition === Position.Left) ||
(sourcePosition === Position.Left && targetPosition === Position.Right);
if (isHorizontalFlow && Math.abs(sourceY - targetY) <= 18) {
return "straight";
}
const deltaX = Math.abs(sourceX - targetX);
const deltaY = Math.abs(sourceY - targetY);
if (deltaX < 40 || deltaY < 40) {
return "smoothstep";
}
return "bezier";
return "smoothstep";
}

View file

@ -90,6 +90,7 @@ export function RecipeStudioPage({
edges,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
configs,
processors,
sheetView,
@ -125,6 +126,7 @@ export function RecipeStudioPage({
edges: state.edges,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
llmAuxVisibility: state.llmAuxVisibility,
configs: state.configs,
processors: state.processors,
sheetView: state.sheetView,
@ -187,8 +189,17 @@ export function RecipeStudioPage({
layoutDirection,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
});
}, [auxNodePositions, auxNodeSizes, configs, edges, layoutDirection, nodes]);
}, [
auxNodePositions,
auxNodeSizes,
configs,
edges,
layoutDirection,
llmAuxVisibility,
nodes,
]);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
@ -358,7 +369,7 @@ export function RecipeStudioPage({
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { path: "auto" },
data: { path: "smoothstep" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}

View file

@ -59,4 +59,3 @@ export function syncSizesRecord(
}
return prev;
}

View file

@ -22,7 +22,9 @@ import {
type BlockKind,
type BlockType,
} from "../blocks/registry";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
import { HANDLE_IDS } from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
import { syncPositionsRecord, syncSizesRecord } from "./helpers/aux-sync";
@ -43,6 +45,7 @@ type RecipeStudioState = {
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
configs: Record<string, NodeConfig>;
processors: RecipeProcessorConfig[];
sheetView: SheetView;
@ -59,6 +62,7 @@ type RecipeStudioState = {
openConfig: (id: string) => void;
setLayoutDirection: (direction: LayoutDirection) => void;
applyLayout: () => void;
setLlmAuxVisibility: (id: string, visible: boolean) => void;
addSamplerNode: (type: SamplerType) => void;
addSeedNode: () => void;
addLlmNode: (type: LlmType) => void;
@ -88,6 +92,7 @@ const INITIAL_STATE = {
edges: [],
auxNodePositions: {},
auxNodeSizes: {},
llmAuxVisibility: {},
configs: {},
processors: [],
sheetView: "root",
@ -102,6 +107,7 @@ const INITIAL_STATE = {
| "edges"
| "auxNodePositions"
| "auxNodeSizes"
| "llmAuxVisibility"
| "configs"
| "processors"
| "sheetView"
@ -127,6 +133,79 @@ function buildAddedNodeState(
return buildNodeUpdate(state, config, state.layoutDirection);
}
function getAddedNodeContext(
update: Partial<RecipeStudioState> | RecipeStudioState,
): {
nodes: RecipeNode[];
configs: Record<string, NodeConfig>;
newNodeId: string;
} | null {
const nodes = "nodes" in update ? update.nodes : null;
const configs = "configs" in update ? update.configs : null;
const newNodeId = "activeConfigId" in update ? update.activeConfigId : null;
if (!(nodes && configs && newNodeId)) {
return null;
}
return { nodes, configs, newNodeId };
}
function placeNodeNear(
nodes: RecipeNode[],
nodeId: string,
anchorId: string,
direction: LayoutDirection,
relation: "before" | "after",
): RecipeNode[] {
const anchor = nodes.find((node) => node.id === anchorId);
if (!anchor) {
return nodes;
}
const primaryOffset = relation === "before" ? -440 : 440;
return nodes.map((node) => {
if (node.id !== nodeId) {
return node;
}
if (direction === "TB") {
return {
...node,
position: {
x: anchor.position.x,
y: anchor.position.y + primaryOffset,
},
};
}
return {
...node,
position: {
x: anchor.position.x + primaryOffset,
y: anchor.position.y,
},
};
});
}
function connectSemantic(
edges: Edge[],
configs: Record<string, NodeConfig>,
sourceId: string,
targetId: string,
): { edges: Edge[]; configs: Record<string, NodeConfig> } {
const result = applyRecipeConnection(
{
source: sourceId,
sourceHandle: HANDLE_IDS.semanticOut,
target: targetId,
targetHandle: HANDLE_IDS.semanticIn,
},
configs,
edges,
);
return {
edges: result.edges,
configs: result.configs ?? configs,
};
}
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
...INITIAL_STATE,
setSheetView: (view) => set({ sheetView: view }),
@ -138,7 +217,6 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
auxNodePositions: {},
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@ -148,20 +226,68 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
applyLayout: () =>
set((state) => {
const isTopBottom = state.layoutDirection === "TB";
const { nodes } = getLayoutedElements(state.nodes, state.edges, {
const displayGraph = deriveDisplayGraph({
nodes: state.nodes,
edges: state.edges,
configs: state.configs,
layoutDirection: state.layoutDirection,
auxNodePositions: state.auxNodePositions,
auxNodeSizes: state.auxNodeSizes,
llmAuxVisibility: state.llmAuxVisibility,
});
const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
direction: state.layoutDirection,
nodesep: isTopBottom ? 120 : 80,
ranksep: isTopBottom ? 140 : 80,
});
const layoutedPositions = new Map(
nodes.map((node) => [node.id, node.position] as const),
);
const nextNodes = state.nodes.map((node) => {
const position = layoutedPositions.get(node.id);
if (!position) {
return node;
}
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: {},
auxNodePositions: nextAuxNodePositions,
nodes: applyLayoutDirectionToNodes(
nodes,
nextNodes,
state.configs,
state.layoutDirection,
),
};
}),
setLlmAuxVisibility: (id, visible) =>
set((state) => {
if (state.llmAuxVisibility[id] === visible) {
return state;
}
return {
llmAuxVisibility: {
...state.llmAuxVisibility,
[id]: visible,
},
};
}),
addSamplerNode: (type) =>
set((state) => buildAddedNodeState(state, "sampler", type)),
addSeedNode: () =>
@ -183,9 +309,94 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
}),
addLlmNode: (type) => set((state) => buildAddedNodeState(state, "llm", type)),
addModelProviderNode: () =>
set((state) => buildAddedNodeState(state, "llm", "model_provider")),
set((state) => {
const added = buildAddedNodeState(state, "llm", "model_provider");
const context = getAddedNodeContext(added);
if (!context) {
return added;
}
let { nodes, configs } = context;
let edges = state.edges;
const unboundModelConfigs = Object.values(configs).filter(
(config) =>
config.kind === "model_config" &&
!config.provider.trim(),
);
if (unboundModelConfigs.length > 0) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
unboundModelConfigs[0].id,
state.layoutDirection,
"before",
);
}
if (unboundModelConfigs.length === 1) {
const next = connectSemantic(
edges,
configs,
context.newNodeId,
unboundModelConfigs[0].id,
);
edges = next.edges;
configs = next.configs;
}
return { ...added, nodes, edges, configs };
}),
addModelConfigNode: () =>
set((state) => buildAddedNodeState(state, "llm", "model_config")),
set((state) => {
const added = buildAddedNodeState(state, "llm", "model_config");
const context = getAddedNodeContext(added);
if (!context) {
return added;
}
let { nodes, configs } = context;
let edges = state.edges;
const providers = Object.values(configs).filter(
(config) => config.kind === "model_provider",
);
const unboundLlms = Object.values(configs).filter(
(config) => config.kind === "llm" && !config.model_alias.trim(),
);
if (providers.length === 1) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
providers[0].id,
state.layoutDirection,
"after",
);
} else if (unboundLlms.length > 0) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
unboundLlms[0].id,
state.layoutDirection,
"before",
);
}
if (providers.length === 1) {
const next = connectSemantic(
edges,
configs,
providers[0].id,
context.newNodeId,
);
edges = next.edges;
configs = next.configs;
}
if (unboundLlms.length === 1) {
const next = connectSemantic(
edges,
configs,
context.newNodeId,
unboundLlms[0].id,
);
edges = next.edges;
configs = next.configs;
}
return { ...added, nodes, edges, configs };
}),
addExpressionNode: () =>
set((state) => buildAddedNodeState(state, "expression", "expression")),
loadRecipe: (snapshot) =>
@ -202,6 +413,7 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
nextY: snapshot.nextY,
auxNodePositions: {},
auxNodeSizes: {},
llmAuxVisibility: {},
activeConfigId: null,
dialogOpen: false,
sheetView: "root",
@ -237,7 +449,12 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults);
return next === state.auxNodePositions ? state : { auxNodePositions: next };
if (next === state.auxNodePositions) {
return state;
}
return {
auxNodePositions: next,
};
}),
syncAuxNodeSizes: (activeIds) =>
set((state) => {
@ -293,7 +510,20 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
removedIds,
);
const nodes = applyNodeChanges<RecipeNode>(changes, state.nodes);
return { nodes, edges: removed.edges, configs: removed.configs };
const llmAuxVisibility =
removedIds.length === 0
? state.llmAuxVisibility
: Object.fromEntries(
Object.entries(state.llmAuxVisibility).filter(
([id]) => !removedIds.includes(id),
),
);
return {
nodes,
edges: removed.edges,
configs: removed.configs,
llmAuxVisibility,
};
};
set(applyNodesChange);
},

View file

@ -13,6 +13,7 @@ type DisplayGraphInput = {
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
};
export type DisplayGraph = {
@ -29,6 +30,7 @@ function normalizeEdge(edge: Edge, configs: Record<string, NodeConfig>): Edge {
return {
...edge,
type: "canvas",
data: { ...(edge.data ?? {}), path: "smoothstep" },
style: { ...baseStyle, ...(edge.style ?? {}) },
};
}
@ -43,6 +45,7 @@ function normalizeEdge(edge: Edge, configs: Record<string, NodeConfig>): Edge {
return {
...edge,
type: semantic ? "semantic" : "canvas",
data: semantic ? edge.data : { ...(edge.data ?? {}), path: "smoothstep" },
...handles,
style: { ...baseStyle, ...(edge.style ?? {}) },
};
@ -54,6 +57,68 @@ type AuxNodeItem = {
data: RecipeGraphAuxNodeData;
};
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
function toRect(
position: XYPosition,
width: number,
height: number,
): Rect {
return {
x: position.x,
y: position.y,
width,
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,
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;
}
}
}
return preferred;
}
export function deriveDisplayGraph({
nodes,
edges,
@ -61,6 +126,7 @@ export function deriveDisplayGraph({
layoutDirection,
auxNodePositions,
auxNodeSizes,
llmAuxVisibility,
}: DisplayGraphInput): DisplayGraph {
const displayNodes = nodes.map((node) => {
const hasWidth =
@ -80,12 +146,22 @@ export function deriveDisplayGraph({
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
const auxNodeIds: string[] = [];
const occupiedRects: Rect[] = displayNodes.map((node) =>
toRect(
node.position,
readNodeWidth(node) ?? DEFAULT_NODE_WIDTH,
readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT,
),
);
for (const node of displayNodes) {
const config = configs[node.id];
if (!(config && config.kind === "llm")) {
continue;
}
if (!llmAuxVisibility[config.id]) {
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
const items: AuxNodeItem[] = [];
@ -160,10 +236,17 @@ export function deriveDisplayGraph({
let xCursor = startX;
for (const entry of itemsWithLayout) {
const defaultPosition = {
const preferredPosition = {
x: xCursor,
y: node.position.y - entry.height - sideOffset,
};
const defaultPosition = findNonOverlappingPosition(
preferredPosition,
entry.width,
entry.height,
llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
xCursor += entry.width + gap;
@ -171,6 +254,7 @@ export function deriveDisplayGraph({
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,
@ -212,10 +296,17 @@ export function deriveDisplayGraph({
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
for (const entry of itemsWithLayout) {
const defaultPosition = {
const preferredPosition = {
x: baseX + (maxWidth - entry.width),
y: yCursor,
};
const defaultPosition = findNonOverlappingPosition(
preferredPosition,
entry.width,
entry.height,
llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
yCursor += entry.height + gap;
@ -223,6 +314,7 @@ export function deriveDisplayGraph({
if (!auxNodePositions[entry.auxId]) {
auxDefaults[entry.auxId] = defaultPosition;
}
occupiedRects.push(toRect(position, entry.width, entry.height));
auxNodes.push({
id: entry.auxId,

View file

@ -6,6 +6,7 @@ type LayoutOptions = {
direction?: LayoutDirection;
nodesep?: number;
ranksep?: number;
edgesep?: number;
nodeWidth?: number;
nodeHeight?: number;
};
@ -19,13 +20,20 @@ export function getLayoutedElements<TNode extends Node>(
direction = "LR",
nodesep = 80,
ranksep = 80,
edgesep = 28,
nodeWidth = 220,
nodeHeight = 64,
} = options;
const graph = new dagre.graphlib.Graph();
graph.setDefaultEdgeLabel(() => ({}));
graph.setGraph({ rankdir: direction, nodesep, ranksep });
graph.setGraph({
rankdir: direction,
nodesep,
ranksep,
edgesep,
ranker: "network-simplex",
});
nodes.forEach((node) => {
const width = node.measured?.width ?? nodeWidth;
@ -34,7 +42,12 @@ export function getLayoutedElements<TNode extends Node>(
});
edges.forEach((edge) => {
graph.setEdge(edge.source, edge.target);
const semantic = edge.type === "semantic";
const aux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-");
graph.setEdge(edge.source, edge.target, {
minlen: semantic ? 1 : 1,
weight: semantic ? 10 : aux ? 1 : 3,
});
});
dagre.layout(graph);