refactor: improve layout direction handling and auxiliary node visibility for LLMS
This commit is contained in:
parent
2cc9981ef9
commit
17a22fe155
8 changed files with 431 additions and 89 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -59,4 +59,3 @@ export function syncSizesRecord(
|
|||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue