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(); + }} + /> ) : (