refactor(recipe-studio): split page logic into graph/runtime hooks + floating run controls
This commit is contained in:
parent
4c97591e4c
commit
bf594c89de
4 changed files with 582 additions and 368 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue