refactor(recipe-studio): split page logic into graph/runtime hooks + floating run controls

This commit is contained in:
Shine1i 2026-02-26 16:42:35 +01:00
commit bf594c89de
4 changed files with 582 additions and 368 deletions

View file

@ -0,0 +1,49 @@
import { CookBookIcon, TestTube01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import { Button } from "@/components/ui/button";
import type { RecipeExecutionKind } from "../../execution-types";
type RunValidateFloatingControlsProps = {
runBusy: boolean;
runDialogKind: RecipeExecutionKind;
validateLoading: boolean;
executionLocked: boolean;
onOpenRunDialog: (kind: RecipeExecutionKind) => void;
onValidate: () => void;
};
export function RunValidateFloatingControls({
runBusy,
runDialogKind,
validateLoading,
executionLocked,
onOpenRunDialog,
onValidate,
}: RunValidateFloatingControlsProps): ReactElement {
return (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2">
<Button
type="button"
className="corner-squircle h-11 px-5"
onClick={() => onOpenRunDialog(runDialogKind)}
disabled={runBusy}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
{runBusy ? "Running..." : "Run"}
</Button>
<Button
type="button"
variant="outline"
className="corner-squircle h-11 px-5"
onClick={onValidate}
disabled={validateLoading || executionLocked}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
{validateLoading ? "Validating..." : "Validate"}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,293 @@
import type {
Edge,
EdgeChange,
Node,
NodeChange,
ReactFlowInstance,
XYPosition,
} from "@xyflow/react";
import {
type DragEvent as ReactDragEvent,
type RefObject,
useCallback,
useMemo,
} from "react";
import { RECIPE_BLOCK_DND_MIME, type RecipeBlockDragPayload } from "../components/block-sheet";
import type { SeedBlockType } from "../blocks/registry";
import type {
LlmType,
NodeConfig,
RecipeNode as RecipeBuilderNode,
RecipeNodeData,
SamplerType,
} from "../types";
import { applyAuxNodeChanges, filterEdgeChangesByIds, filterNodeChangesByIds } from "../utils/reactflow-changes";
import type { RecipeGraphAuxNodeData } from "../components/recipe-graph-aux-node";
const SUPPORTED_DRAG_KINDS: RecipeBlockDragPayload["kind"][] = [
"sampler",
"seed",
"llm",
"expression",
"note",
];
function parseRecipeBlockDragPayload(raw: string): RecipeBlockDragPayload | null {
try {
const parsed = JSON.parse(raw) as {
kind?: RecipeBlockDragPayload["kind"];
type?: RecipeBlockDragPayload["type"];
};
if (!parsed.kind || !parsed.type || !SUPPORTED_DRAG_KINDS.includes(parsed.kind)) {
return null;
}
return {
kind: parsed.kind,
type: parsed.type,
};
} catch {
return null;
}
}
type UseRecipeEditorGraphArgs = {
nodes: RecipeBuilderNode[];
edges: Edge[];
configs: Record<string, NodeConfig>;
reactFlowInstance: ReactFlowInstance<Node<RecipeNodeData | RecipeGraphAuxNodeData>, Edge> | null;
flowContainerRef: RefObject<HTMLDivElement | null>;
selectConfig: (id: string) => void;
openConfig: (id: string) => void;
onNodesChange: (changes: NodeChange<RecipeBuilderNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
addSamplerNode: (type: SamplerType, position?: XYPosition, openDialog?: boolean) => void;
addSeedNode: (type: SeedBlockType, position?: XYPosition, openDialog?: boolean) => void;
addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
addMarkdownNoteNode: (position?: XYPosition, openDialog?: boolean) => void;
};
type UseRecipeEditorGraphResult = {
handleNodeClick: (_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => void;
handleNodeDoubleClick: (_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => void;
handleNodesChange: (
changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[],
) => void;
handleEdgesChange: (changes: EdgeChange<Edge>[]) => void;
handleDragOver: (event: ReactDragEvent<HTMLDivElement>) => void;
handleDrop: (event: ReactDragEvent<HTMLDivElement>) => void;
handleAddSamplerFromSheet: (type: SamplerType) => void;
handleAddSeedFromSheet: (type: SeedBlockType) => void;
handleAddLlmFromSheet: (type: LlmType) => void;
handleAddModelProviderFromSheet: () => void;
handleAddModelConfigFromSheet: () => void;
handleAddExpressionFromSheet: () => void;
handleAddMarkdownNoteFromSheet: () => void;
};
export function useRecipeEditorGraph({
nodes,
edges,
configs,
reactFlowInstance,
flowContainerRef,
selectConfig,
openConfig,
onNodesChange,
onEdgesChange,
setAuxNodePosition,
addSamplerNode,
addSeedNode,
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
addMarkdownNoteNode,
}: UseRecipeEditorGraphArgs): UseRecipeEditorGraphResult {
const baseNodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes]);
const baseEdgeIds = useMemo(() => new Set(edges.map((edge) => edge.id)), [edges]);
const handleNodeClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
selectConfig(node.id);
},
[selectConfig],
);
const handleNodeDoubleClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
const nodeConfig = configs[node.id];
if (nodeConfig?.kind === "markdown_note") {
openConfig(node.id);
}
},
[configs, openConfig],
);
const handleNodesChange = useCallback(
(changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition });
const next = filterNodeChangesByIds(
changes as NodeChange<RecipeBuilderNode>[],
baseNodeIds,
);
if (next.length) {
onNodesChange(next);
}
},
[baseNodeIds, onNodesChange, setAuxNodePosition],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
const next = filterEdgeChangesByIds(changes, baseEdgeIds);
if (next.length) {
onEdgesChange(next);
}
},
[baseEdgeIds, onEdgesChange],
);
const handleDragOver = useCallback((event: ReactDragEvent<HTMLDivElement>) => {
if (
!event.dataTransfer.types.includes(RECIPE_BLOCK_DND_MIME) &&
!event.dataTransfer.types.includes("text/plain")
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!reactFlowInstance) {
return;
}
const raw =
event.dataTransfer.getData(RECIPE_BLOCK_DND_MIME) ||
event.dataTransfer.getData("text/plain");
if (!raw) {
return;
}
const payload = parseRecipeBlockDragPayload(raw);
if (!payload) {
return;
}
event.preventDefault();
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (payload.kind === "sampler") {
addSamplerNode(payload.type as SamplerType, position, false);
return;
}
if (payload.kind === "seed") {
addSeedNode(payload.type as SeedBlockType, position, false);
return;
}
if (payload.kind === "expression") {
addExpressionNode(position, false);
return;
}
if (payload.kind === "note") {
addMarkdownNoteNode(position, false);
return;
}
if (payload.type === "model_provider") {
addModelProviderNode(position, false);
return;
}
if (payload.type === "model_config") {
addModelConfigNode(position, false);
return;
}
addLlmNode(payload.type as LlmType, position, false);
},
[
addExpressionNode,
addLlmNode,
addMarkdownNoteNode,
addModelConfigNode,
addModelProviderNode,
addSamplerNode,
addSeedNode,
reactFlowInstance,
],
);
const getViewportCenterPosition = useCallback(() => {
if (!reactFlowInstance || !flowContainerRef.current) {
return undefined;
}
const rect = flowContainerRef.current.getBoundingClientRect();
return reactFlowInstance.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}, [flowContainerRef, reactFlowInstance]);
const handleAddSamplerFromSheet = useCallback(
(type: SamplerType) => {
addSamplerNode(type, getViewportCenterPosition());
},
[addSamplerNode, getViewportCenterPosition],
);
const handleAddSeedFromSheet = useCallback(
(type: SeedBlockType) => {
addSeedNode(type, getViewportCenterPosition());
},
[addSeedNode, getViewportCenterPosition],
);
const handleAddLlmFromSheet = useCallback(
(type: LlmType) => {
addLlmNode(type, getViewportCenterPosition());
},
[addLlmNode, getViewportCenterPosition],
);
const handleAddModelProviderFromSheet = useCallback(() => {
addModelProviderNode(getViewportCenterPosition());
}, [addModelProviderNode, getViewportCenterPosition]);
const handleAddModelConfigFromSheet = useCallback(() => {
addModelConfigNode(getViewportCenterPosition());
}, [addModelConfigNode, getViewportCenterPosition]);
const handleAddExpressionFromSheet = useCallback(() => {
addExpressionNode(getViewportCenterPosition());
}, [addExpressionNode, getViewportCenterPosition]);
const handleAddMarkdownNoteFromSheet = useCallback(() => {
addMarkdownNoteNode(getViewportCenterPosition());
}, [addMarkdownNoteNode, getViewportCenterPosition]);
return {
handleNodeClick,
handleNodeDoubleClick,
handleNodesChange,
handleEdgesChange,
handleDragOver,
handleDrop,
handleAddSamplerFromSheet,
handleAddSeedFromSheet,
handleAddLlmFromSheet,
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddExpressionFromSheet,
handleAddMarkdownNoteFromSheet,
};
}

View file

@ -0,0 +1,175 @@
import {
BalanceScaleIcon,
Clock01Icon,
CodeIcon,
CodeSimpleIcon,
DiceFaces03Icon,
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
Shield02Icon,
Tag01Icon,
TagsIcon,
UserAccountIcon,
} from "@hugeicons/core-free-icons";
import { useMemo } from "react";
import type { Edge } from "@xyflow/react";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import {
deriveGraphRuntimeVisualState,
pickLatestActiveExecution,
} from "../utils/graph/runtime-visual-state";
import type {
LayoutDirection,
LlmType,
NodeConfig,
RecipeNode as RecipeBuilderNode,
SamplerType,
} from "../types";
import type { RecipeExecutionRecord } from "../execution-types";
type IconType = typeof CodeIcon;
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
category: Tag01Icon,
subcategory: TagsIcon,
uniform: EqualSignIcon,
gaussian: Parabola02Icon,
bernoulli: EqualSignIcon,
datetime: Clock01Icon,
timedelta: Clock01Icon,
uuid: FingerPrintIcon,
person: UserAccountIcon,
person_from_faker: UserAccountIcon,
};
const LLM_ICONS: Record<LlmType, IconType> = {
text: PencilEdit02Icon,
structured: CodeIcon,
code: CodeSimpleIcon,
judge: BalanceScaleIcon,
};
function resolveExecutionColumnIcon(config: NodeConfig | null): IconType {
if (!config) {
return DiceFaces03Icon;
}
if (config.kind === "sampler") {
return SAMPLER_ICONS[config.sampler_type];
}
if (config.kind === "llm") {
return LLM_ICONS[config.llm_type];
}
if (config.kind === "expression") {
return FunctionIcon;
}
if (config.kind === "seed") {
return Plant01Icon;
}
if (config.kind === "model_provider") {
return Shield02Icon;
}
if (config.kind === "model_config") {
return Plant01Icon;
}
return PencilEdit02Icon;
}
type UseRecipeRuntimeVisualsArgs = {
executions: RecipeExecutionRecord[];
configs: Record<string, NodeConfig>;
nodes: RecipeBuilderNode[];
edges: Edge[];
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, { x: number; y: number }>;
llmAuxVisibility: Record<string, boolean>;
};
type UseRecipeRuntimeVisualsResult = {
activeExecution: RecipeExecutionRecord | null;
runtimeVisualState: ReturnType<typeof deriveGraphRuntimeVisualState>;
displayGraph: ReturnType<typeof deriveDisplayGraph>;
displayNodeIds: string[];
currentColumnIcon: IconType;
};
export function useRecipeRuntimeVisuals({
executions,
configs,
nodes,
edges,
layoutDirection,
auxNodePositions,
llmAuxVisibility,
}: UseRecipeRuntimeVisualsArgs): UseRecipeRuntimeVisualsResult {
const activeExecution = useMemo(
() => pickLatestActiveExecution(executions),
[executions],
);
const runtimeVisualState = useMemo(
() =>
deriveGraphRuntimeVisualState({
activeExecution,
configs,
edges,
}),
[activeExecution, configs, edges],
);
const displayGraph = useMemo(
() =>
deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
llmAuxVisibility,
runtime: runtimeVisualState,
}),
[
auxNodePositions,
configs,
edges,
layoutDirection,
llmAuxVisibility,
nodes,
runtimeVisualState,
],
);
const currentColumnConfig = useMemo(() => {
const columnName = activeExecution?.current_column?.trim();
if (!columnName) {
return null;
}
for (const config of Object.values(configs)) {
if (config.name.trim() === columnName) {
return config;
}
}
return null;
}, [activeExecution?.current_column, configs]);
const currentColumnIcon = useMemo(
() => resolveExecutionColumnIcon(currentColumnConfig),
[currentColumnConfig],
);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
);
return {
activeExecution,
runtimeVisualState,
displayGraph,
displayNodeIds,
currentColumnIcon,
};
}

View file

@ -2,38 +2,16 @@ import {
Background,
BackgroundVariant,
type Edge,
type EdgeChange,
type EdgeTypes,
type Node,
type NodeChange,
type NodeTypes,
Panel,
ReactFlow,
type ReactFlowInstance,
} from "@xyflow/react";
import {
BalanceScaleIcon,
Clock01Icon,
CodeIcon,
CodeSimpleIcon,
CookBookIcon,
DiceFaces03Icon,
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
PlusSignIcon,
Shield02Icon,
Tag01Icon,
TagsIcon,
TestTube01Icon,
UserAccountIcon,
} from "@hugeicons/core-free-icons";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type DragEvent as ReactDragEvent,
type ReactElement,
useCallback,
useEffect,
@ -46,10 +24,9 @@ import "@xyflow/react/dist/style.css";
import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node";
import {
BlockSheet,
RECIPE_BLOCK_DND_MIME,
type RecipeBlockDragPayload,
} from "./components/block-sheet";
import { LayoutControls } from "./components/controls/layout-controls";
import { RunValidateFloatingControls } from "./components/controls/run-validate-floating-controls";
import { ViewportControls } from "./components/controls/viewport-controls";
import { ExecutionsView } from "./components/executions/executions-view";
import { InternalsSync } from "./components/graph/internals-sync";
@ -57,117 +34,24 @@ import { RecipeStudioHeader } from "./components/recipe-studio-header";
import { RecipeNode } from "./components/recipe-graph-node";
import { RecipeGraphSemanticEdge } from "./components/recipe-graph-semantic-edge";
import { DataEdge } from "./components/rf-ui/data-edge";
import { Button } from "@/components/ui/button";
import { ConfigDialog } from "./dialogs/config-dialog";
import { ImportDialog } from "./dialogs/import-dialog";
import { RunDialog } from "./dialogs/preview-dialog";
import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { useRecipeEditorGraph } from "./hooks/use-recipe-editor-graph";
import { useRecipeRuntimeVisuals } from "./hooks/use-recipe-runtime-visuals";
import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions";
import { useRecipeStudioStore } from "./stores/recipe-studio";
import type {
LlmType,
NodeConfig,
RecipeNode as RecipeBuilderNode,
RecipeNodeData,
SamplerType,
} from "./types";
import type { SeedBlockType } from "./blocks/registry";
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
import type { RecipeNodeData } from "./types";
import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view";
import {
deriveGraphRuntimeVisualState,
pickLatestActiveExecution,
} from "./utils/graph/runtime-visual-state";
import { buildRecipePayload } from "./utils/payload";
import type { RecipePayload } from "./utils/payload/types";
import { buildDefaultSchemaTransform } from "./utils/processors";
import {
applyAuxNodeChanges,
filterEdgeChangesByIds,
filterNodeChangesByIds,
} from "./utils/reactflow-changes";
import {
buildDialogOptions,
} from "./utils/recipe-studio-view";
import { buildDialogOptions } from "./utils/recipe-studio-view";
import type { RecipeStudioView } from "./execution-types";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
type IconType = typeof CodeIcon;
const SUPPORTED_DRAG_KINDS: RecipeBlockDragPayload["kind"][] = [
"sampler",
"seed",
"llm",
"expression",
"note",
];
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
category: Tag01Icon,
subcategory: TagsIcon,
uniform: EqualSignIcon,
gaussian: Parabola02Icon,
bernoulli: EqualSignIcon,
datetime: Clock01Icon,
timedelta: Clock01Icon,
uuid: FingerPrintIcon,
person: UserAccountIcon,
person_from_faker: UserAccountIcon,
};
const LLM_ICONS: Record<LlmType, IconType> = {
text: PencilEdit02Icon,
structured: CodeIcon,
code: CodeSimpleIcon,
judge: BalanceScaleIcon,
};
function resolveExecutionColumnIcon(config: NodeConfig | null): IconType {
if (!config) {
return DiceFaces03Icon;
}
if (config.kind === "sampler") {
return SAMPLER_ICONS[config.sampler_type];
}
if (config.kind === "llm") {
return LLM_ICONS[config.llm_type];
}
if (config.kind === "expression") {
return FunctionIcon;
}
if (config.kind === "seed") {
return Plant01Icon;
}
if (config.kind === "model_provider") {
return Shield02Icon;
}
if (config.kind === "model_config") {
return Plant01Icon;
}
return PencilEdit02Icon;
}
function parseRecipeBlockDragPayload(raw: string): RecipeBlockDragPayload | null {
try {
const parsed = JSON.parse(raw) as {
kind?: RecipeBlockDragPayload["kind"];
type?: RecipeBlockDragPayload["type"];
};
if (
!parsed.kind ||
!parsed.type ||
!SUPPORTED_DRAG_KINDS.includes(parsed.kind)
) {
return null;
}
return {
kind: parsed.kind,
type: parsed.type,
};
} catch {
return null;
}
}
export type PersistRecipeInput = {
id: string | null;
@ -286,172 +170,39 @@ export function RecipeStudioPage({
const handlePreviewSuccess = useCallback(() => {
setActiveView("executions");
}, []);
const baseNodeIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
[nodes],
);
const baseEdgeIds = useMemo(
() => new Set(edges.map((edge) => edge.id)),
[edges],
);
const handleNodeClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
selectConfig(node.id);
},
[selectConfig],
);
const handleNodeDoubleClick = useCallback(
(_: unknown, node: Node<RecipeNodeData | RecipeGraphAuxNodeData>) => {
if (node.type !== "builder") {
return;
}
const nodeConfig = configs[node.id];
if (nodeConfig?.kind === "markdown_note") {
openConfig(node.id);
}
},
[configs, openConfig],
);
const handleNodesChange = useCallback(
(changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition });
const next = filterNodeChangesByIds(
changes as NodeChange<RecipeBuilderNode>[],
baseNodeIds,
);
if (next.length) {
onNodesChange(next);
}
},
[baseNodeIds, onNodesChange, setAuxNodePosition],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
const next = filterEdgeChangesByIds(changes, baseEdgeIds);
if (next.length) {
onEdgesChange(next);
}
},
[baseEdgeIds, onEdgesChange],
);
const handleDragOver = useCallback((event: ReactDragEvent<HTMLDivElement>) => {
if (
!event.dataTransfer.types.includes(RECIPE_BLOCK_DND_MIME) &&
!event.dataTransfer.types.includes("text/plain")
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!reactFlowInstance) {
return;
}
const raw =
event.dataTransfer.getData(RECIPE_BLOCK_DND_MIME) ||
event.dataTransfer.getData("text/plain");
if (!raw) {
return;
}
const payload = parseRecipeBlockDragPayload(raw);
if (!payload) {
return;
}
event.preventDefault();
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
if (payload.kind === "sampler") {
addSamplerNode(payload.type as SamplerType, position, false);
return;
}
if (payload.kind === "seed") {
addSeedNode(payload.type as SeedBlockType, position, false);
return;
}
if (payload.kind === "expression") {
addExpressionNode(position, false);
return;
}
if (payload.kind === "note") {
addMarkdownNoteNode(position, false);
return;
}
if (payload.type === "model_provider") {
addModelProviderNode(position, false);
return;
}
if (payload.type === "model_config") {
addModelConfigNode(position, false);
return;
}
addLlmNode(payload.type as LlmType, position, false);
},
[
addExpressionNode,
addLlmNode,
addMarkdownNoteNode,
addModelConfigNode,
addModelProviderNode,
addSamplerNode,
addSeedNode,
reactFlowInstance,
],
);
const getViewportCenterPosition = useCallback(() => {
if (!reactFlowInstance || !flowContainerRef.current) {
return undefined;
}
const rect = flowContainerRef.current.getBoundingClientRect();
return reactFlowInstance.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}, [reactFlowInstance]);
const handleAddSamplerFromSheet = useCallback(
(type: SamplerType) => {
addSamplerNode(type, getViewportCenterPosition());
},
[addSamplerNode, getViewportCenterPosition],
);
const handleAddSeedFromSheet = useCallback(
(type: SeedBlockType) => {
addSeedNode(type, getViewportCenterPosition());
},
[addSeedNode, getViewportCenterPosition],
);
const handleAddLlmFromSheet = useCallback(
(type: LlmType) => {
addLlmNode(type, getViewportCenterPosition());
},
[addLlmNode, getViewportCenterPosition],
);
const handleAddModelProviderFromSheet = useCallback(() => {
addModelProviderNode(getViewportCenterPosition());
}, [addModelProviderNode, getViewportCenterPosition]);
const handleAddModelConfigFromSheet = useCallback(() => {
addModelConfigNode(getViewportCenterPosition());
}, [addModelConfigNode, getViewportCenterPosition]);
const handleAddExpressionFromSheet = useCallback(() => {
addExpressionNode(getViewportCenterPosition());
}, [addExpressionNode, getViewportCenterPosition]);
const handleAddMarkdownNoteFromSheet = useCallback(() => {
addMarkdownNoteNode(getViewportCenterPosition());
}, [addMarkdownNoteNode, getViewportCenterPosition]);
const {
handleNodeClick,
handleNodeDoubleClick,
handleNodesChange,
handleEdgesChange,
handleDragOver,
handleDrop,
handleAddSamplerFromSheet,
handleAddSeedFromSheet,
handleAddLlmFromSheet,
handleAddModelProviderFromSheet,
handleAddModelConfigFromSheet,
handleAddExpressionFromSheet,
handleAddMarkdownNoteFromSheet,
} = useRecipeEditorGraph({
nodes,
edges,
configs,
reactFlowInstance,
flowContainerRef,
selectConfig,
openConfig,
onNodesChange,
onEdgesChange,
setAuxNodePosition,
addSamplerNode,
addSeedNode,
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
addMarkdownNoteNode,
});
const configList = useMemo(() => Object.values(configs), [configs]);
const config = activeConfigId ? configs[activeConfigId] : null;
@ -536,63 +287,24 @@ export function RecipeStudioPage({
onExecutionStart: handleExecutionStart,
onPreviewSuccess: handlePreviewSuccess,
});
const activeExecution = useMemo(
() => pickLatestActiveExecution(executions),
[executions],
);
const runtimeVisualState = useMemo(
() =>
deriveGraphRuntimeVisualState({
activeExecution,
configs,
edges,
}),
[activeExecution, configs, edges],
);
const displayGraph = useMemo(
() =>
deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
llmAuxVisibility,
runtime: runtimeVisualState,
}),
[
auxNodePositions,
configs,
edges,
layoutDirection,
llmAuxVisibility,
nodes,
runtimeVisualState,
],
);
const {
activeExecution,
runtimeVisualState,
displayGraph,
displayNodeIds,
currentColumnIcon,
} = useRecipeRuntimeVisuals({
executions,
configs,
nodes,
edges,
layoutDirection,
auxNodePositions,
llmAuxVisibility,
});
const executionLocked = runtimeVisualState.executionLocked;
const canvasInteractive = interactive && !executionLocked;
const runBusy = previewLoading || fullLoading || executionLocked;
const currentColumnConfig = useMemo(() => {
const columnName = activeExecution?.current_column?.trim();
if (!columnName) {
return null;
}
for (const config of Object.values(configs)) {
if (config.name.trim() === columnName) {
return config;
}
}
return null;
}, [activeExecution?.current_column, configs]);
const currentColumnIcon = useMemo(
() => resolveExecutionColumnIcon(currentColumnConfig),
[currentColumnConfig],
);
const displayNodeIds = useMemo(
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
);
const toggleInteractive = useCallback(() => {
if (executionLocked) {
@ -781,32 +493,17 @@ export function RecipeStudioPage({
</div>
</Panel>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center">
<div className="pointer-events-auto flex items-center gap-2">
<Button
type="button"
className="h-11 px-5"
onClick={() => openRunDialog(runDialogKind)}
disabled={runBusy}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
{runBusy ? "Running..." : "Run"}
</Button>
<Button
type="button"
variant="outline"
className="h-11 px-5"
onClick={() => {
openRunDialog(runDialogKind);
void validateFromDialog();
}}
disabled={validateLoading || executionLocked}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
{validateLoading ? "Validating..." : "Validate"}
</Button>
</div>
</div>
<RunValidateFloatingControls
runBusy={runBusy}
runDialogKind={runDialogKind}
validateLoading={validateLoading}
executionLocked={executionLocked}
onOpenRunDialog={openRunDialog}
onValidate={() => {
openRunDialog(runDialogKind);
void validateFromDialog();
}}
/>
</ReactFlow>
) : (
<ExecutionsView