diff --git a/studio/frontend/src/features/recipe-studio/components/controls/run-validate-floating-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/run-validate-floating-controls.tsx
new file mode 100644
index 0000000000..c8ca937d1e
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/components/controls/run-validate-floating-controls.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts
new file mode 100644
index 0000000000..cdefd6c8ef
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-editor-graph.ts
@@ -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;
+ reactFlowInstance: ReactFlowInstance, Edge> | null;
+ flowContainerRef: RefObject;
+ selectConfig: (id: string) => void;
+ openConfig: (id: string) => void;
+ onNodesChange: (changes: NodeChange[]) => void;
+ onEdgesChange: (changes: EdgeChange[]) => 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) => void;
+ handleNodeDoubleClick: (_: unknown, node: Node) => void;
+ handleNodesChange: (
+ changes: NodeChange>[],
+ ) => void;
+ handleEdgesChange: (changes: EdgeChange[]) => void;
+ handleDragOver: (event: ReactDragEvent) => void;
+ handleDrop: (event: ReactDragEvent) => 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) => {
+ if (node.type !== "builder") {
+ return;
+ }
+ selectConfig(node.id);
+ },
+ [selectConfig],
+ );
+
+ const handleNodeDoubleClick = useCallback(
+ (_: unknown, node: Node) => {
+ 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>[]) => {
+ applyAuxNodeChanges(changes, { setAuxNodePosition });
+ const next = filterNodeChangesByIds(
+ changes as NodeChange[],
+ baseNodeIds,
+ );
+ if (next.length) {
+ onNodesChange(next);
+ }
+ },
+ [baseNodeIds, onNodesChange, setAuxNodePosition],
+ );
+
+ const handleEdgesChange = useCallback(
+ (changes: EdgeChange[]) => {
+ const next = filterEdgeChangesByIds(changes, baseEdgeIds);
+ if (next.length) {
+ onEdgesChange(next);
+ }
+ },
+ [baseEdgeIds, onEdgesChange],
+ );
+
+ const handleDragOver = useCallback((event: ReactDragEvent) => {
+ 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) => {
+ 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,
+ };
+}
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts
new file mode 100644
index 0000000000..316ccdc6c0
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-runtime-visuals.ts
@@ -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 = {
+ 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 = {
+ 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;
+ nodes: RecipeBuilderNode[];
+ edges: Edge[];
+ layoutDirection: LayoutDirection;
+ auxNodePositions: Record;
+ llmAuxVisibility: Record;
+};
+
+type UseRecipeRuntimeVisualsResult = {
+ activeExecution: RecipeExecutionRecord | null;
+ runtimeVisualState: ReturnType;
+ displayGraph: ReturnType;
+ 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,
+ };
+}
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index cfe6b72250..bacd5506aa 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -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 = {
- 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 = {
- 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) => {
- if (node.type !== "builder") {
- return;
- }
- selectConfig(node.id);
- },
- [selectConfig],
- );
-
- const handleNodeDoubleClick = useCallback(
- (_: unknown, node: Node) => {
- 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>[]) => {
- applyAuxNodeChanges(changes, { setAuxNodePosition });
- const next = filterNodeChangesByIds(
- changes as NodeChange[],
- baseNodeIds,
- );
- if (next.length) {
- onNodesChange(next);
- }
- },
- [baseNodeIds, onNodesChange, setAuxNodePosition],
- );
-
- const handleEdgesChange = useCallback(
- (changes: EdgeChange[]) => {
- const next = filterEdgeChangesByIds(changes, baseEdgeIds);
- if (next.length) {
- onEdgesChange(next);
- }
- },
- [baseEdgeIds, onEdgesChange],
- );
-
- const handleDragOver = useCallback((event: ReactDragEvent) => {
- 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) => {
- 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({
)}
-
-
-
-
-
-
+ {
+ openRunDialog(runDialogKind);
+ void validateFromDialog();
+ }}
+ />
) : (