diff --git a/.gitignore b/.gitignore index 044775e846..8b54712a6c 100755 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ unsloth_compiled_cache/ outputs/ exports/ /datasets/ +studio/backend/assets/datasets/ unsloth_training_checkpoints/ *.gguf *.safetensors diff --git a/studio/backend/core/data_recipe/jobs/manager.py b/studio/backend/core/data_recipe/jobs/manager.py index dbaa620004..2a8b1fe44d 100644 --- a/studio/backend/core/data_recipe/jobs/manager.py +++ b/studio/backend/core/data_recipe/jobs/manager.py @@ -138,6 +138,7 @@ class JobManager: "status": job.status, "stage": job.stage, "current_column": job.current_column, + "completed_columns": list(job.completed_columns), "batch": {"idx": job.batch.idx, "total": job.batch.total}, "progress": { "done": job.progress.done, diff --git a/studio/backend/core/data_recipe/jobs/parse.py b/studio/backend/core/data_recipe/jobs/parse.py index 6e2142adf2..1be9fbd34a 100644 --- a/studio/backend/core/data_recipe/jobs/parse.py +++ b/studio/backend/core/data_recipe/jobs/parse.py @@ -151,6 +151,15 @@ def apply_update(job: Job, update: ParsedUpdate) -> None: job.cols = update.cols if update.progress is not None: job.column_progress = update.progress + if ( + job.current_column + and update.progress.done is not None + and update.progress.total is not None + and update.progress.total > 0 + and update.progress.done >= update.progress.total + and job.current_column not in job.completed_columns + ): + job.completed_columns.append(job.current_column) job.progress = _compute_overall_progress(job, update.progress) if update.batch_idx is not None: job.batch.idx = update.batch_idx diff --git a/studio/backend/core/data_recipe/jobs/types.py b/studio/backend/core/data_recipe/jobs/types.py index 24a63d062c..80bd03cf77 100644 --- a/studio/backend/core/data_recipe/jobs/types.py +++ b/studio/backend/core/data_recipe/jobs/types.py @@ -66,6 +66,7 @@ class Job: processor_artifacts: dict[str, Any] | None = None model_usage: dict[str, ModelUsage] = field(default_factory=dict) progress_columns_total: int | None = None + completed_columns: list[str] = field(default_factory=list) _current_usage_model: str | None = None _in_usage_summary: bool = False _seen_generation_columns: list[str] = field(default_factory=list) diff --git a/studio/frontend/src/features/recipe-studio/api/index.ts b/studio/frontend/src/features/recipe-studio/api/index.ts index a654580ef1..88a6c91d21 100644 --- a/studio/frontend/src/features/recipe-studio/api/index.ts +++ b/studio/frontend/src/features/recipe-studio/api/index.ts @@ -15,6 +15,8 @@ export type JobStatusResponse = { stage?: string | null; // biome-ignore lint/style/useNamingConvention: api schema current_column?: string | null; + // biome-ignore lint/style/useNamingConvention: api schema + completed_columns?: string[] | null; batch?: { idx?: number | null; total?: number | null; diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx index e104841100..2ba13bfb33 100644 --- a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx +++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx @@ -7,11 +7,13 @@ import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-butto type ViewportControlsProps = { interactive: boolean; + lockDisabled?: boolean; onToggleInteractive: () => void; }; export function ViewportControls({ interactive, + lockDisabled = false, onToggleInteractive, }: ViewportControlsProps): ReactElement { const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow(); @@ -68,6 +70,7 @@ export function ViewportControls({ variant="ghost" size="icon" className={RECIPE_FLOATING_ICON_BUTTON_CLASS} + disabled={lockDisabled} onClick={onToggleInteractive} aria-label={interactive ? "Lock interaction" : "Unlock interaction"} > diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx index bbb5dead41..8559f3e05c 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx @@ -25,12 +25,14 @@ type PromptInputNodeData = { llmId: string; field: PromptField; title: string; + executionLocked?: boolean; }; type JudgeScoreNodeData = { kind: "llm-judge-score"; llmId: string; scoreIndex: number; + executionLocked?: boolean; }; export type RecipeGraphAuxNodeData = PromptInputNodeData | JudgeScoreNodeData; @@ -81,6 +83,7 @@ function AuxNodeBase({ if (!(config && config.kind === "llm")) { return null; } + const executionLocked = Boolean(data.executionLocked); const sourceHandles = ( <> @@ -135,6 +138,7 @@ function AuxNodeBase({ className="corner-squircle nodrag nowheel max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs" aria-invalid={hasInvalidRefs} value={value} + disabled={executionLocked} onChange={(event) => updateConfig(data.llmId, { [data.field]: event.target.value, @@ -193,7 +197,14 @@ function AuxNodeBase({ {score.name.trim() || `Scorer ${data.scoreIndex + 1}`} - + Remove @@ -202,12 +213,14 @@ function AuxNodeBase({ className="nodrag h-7 w-full text-xs" placeholder="Score name" value={score.name} + disabled={executionLocked} onChange={(event) => updateScore({ name: event.target.value })} /> updateScore({ description: event.target.value })} /> @@ -217,6 +230,7 @@ function AuxNodeBase({ className="nodrag h-7 text-xs" placeholder="Value" value={option.value} + disabled={executionLocked} onChange={(event) => updateOption(optionIndex, { value: event.target.value }) } @@ -225,6 +239,7 @@ function AuxNodeBase({ className="nodrag h-7 text-xs" placeholder="Description" value={option.description} + disabled={executionLocked} onChange={(event) => updateOption(optionIndex, { description: event.target.value, @@ -236,13 +251,21 @@ function AuxNodeBase({ size="xs" variant="ghost" className="nodrag" + disabled={executionLocked} onClick={() => removeOption(optionIndex)} > x ))} - + Add option diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx index 0c046659e5..0427133c0c 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx @@ -290,6 +290,8 @@ function RecipeGraphNodeBase({ (state) => state.setLlmAuxVisibility, ); const updateNodeInternals = useUpdateNodeInternals(); + const executionLocked = Boolean(data.executionLocked); + const runtimeState = data.runtimeState ?? "idle"; useEffect(() => { updateNodeInternals(id); @@ -350,9 +352,28 @@ function RecipeGraphNodeBase({ config.sampler_type === "person_from_faker") ? USER_NODE_TONE : meta.tone; + const runtimeNodeTone = + runtimeState === "running" + ? "border-primary/70 ring-2 ring-primary/20 shadow-md" + : runtimeState === "done" + ? "border-emerald-500/60 ring-1 ring-emerald-500/20" + : ""; return ( - + + {runtimeState === "running" && config?.kind === "llm" && ( + + + + )} { event.preventDefault(); event.stopPropagation(); @@ -400,14 +422,15 @@ function RecipeGraphNodeBase({ {llmAuxVisible ? "Hide inputs" : "Show inputs"} )} - { - event.preventDefault(); - event.stopPropagation(); + className="nodrag" + disabled={executionLocked} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); openConfig(id); }} > @@ -416,7 +439,12 @@ function RecipeGraphNodeBase({ - + {nodeBody} diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx index 16ecc33553..9908a91f92 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-semantic-edge.tsx @@ -12,7 +12,9 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({ style, markerEnd, selected, + data, }: EdgeProps): ReactElement { + const isActive = Boolean((data as { active?: boolean } | undefined)?.active); const [path] = getSmoothStepPath({ sourceX, sourceY, @@ -30,12 +32,10 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({ path={path} markerEnd={markerEnd} style={{ - strokeDasharray: selected ? "7 5" : "6 5", - strokeWidth: selected ? 2.3 : 1.8, - stroke: selected - ? "hsl(var(--primary) / 0.9)" - : "hsl(var(--foreground) / 0.38)", - opacity: selected ? 1 : 0.92, + strokeDasharray: isActive ? "8 6" : selected ? "7 5" : "6 5", + strokeWidth: isActive ? 2.4 : selected ? 2.3 : 1.8, + stroke: isActive || selected ? "var(--primary)" : "var(--muted-foreground)", + opacity: isActive ? 1 : selected ? 0.95 : 0.62, ...style, }} /> diff --git a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx index ff9368f1d3..ee475b7459 100644 --- a/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx +++ b/studio/frontend/src/features/recipe-studio/components/rf-ui/data-edge.tsx @@ -11,6 +11,7 @@ import { export type DataEdge = Edge<{ path?: "auto" | "bezier" | "smoothstep" | "step" | "straight"; + active?: boolean; }>; export function DataEdge({ @@ -29,6 +30,7 @@ export function DataEdge({ const resolvedPathType = resolvePathType({ type: data.path ?? "auto", }); + const isActive = Boolean(data.active); const [edgePath] = getPath({ type: resolvedPathType, sourceX, @@ -40,16 +42,20 @@ export function DataEdge({ }); const edgeStyle = { - stroke: selected - ? "hsl(var(--primary) / 0.92)" - : "hsl(var(--foreground) / 0.42)", - strokeWidth: selected ? 2.6 : 2.1, - opacity: selected ? 1 : 0.92, + stroke: isActive || selected ? "var(--primary)" : "var(--muted-foreground)", + strokeWidth: isActive ? 2.6 : selected ? 2.6 : 2.1, + opacity: isActive ? 1 : selected ? 0.96 : 0.7, + strokeDasharray: isActive ? "8 6" : undefined, ...style, }; return ( - + ); } diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx index 1421ce1ffd..6f9c20c58b 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx @@ -18,6 +18,7 @@ type ConfigDialogProps = { datetimeOptions: string[]; onUpdate: (id: string, patch: Partial) => void; container?: HTMLDivElement | null; + readOnly?: boolean; }; export function ConfigDialog({ @@ -30,6 +31,7 @@ export function ConfigDialog({ datetimeOptions, onUpdate, container, + readOnly = false, }: ConfigDialogProps): ReactElement { const blockDefinition = getBlockDefinitionForConfig(config); const showDropToggle = @@ -63,30 +65,38 @@ export function ConfigDialog({ )} {config && ( - - {showDropToggle && ( - - - Drop from final dataset - - Keep for generation but omit from exported rows. - - - onUpdate(config.id, { drop: value })} - /> + {readOnly && ( + + Recipe locked while execution is active. )} - {renderBlockDialog( - config, - open, - categoryOptions, - modelConfigAliases, - modelProviderOptions, - datetimeOptions, - onUpdate, - )} + + + {showDropToggle && ( + + + Drop from final dataset + + Keep for generation but omit from exported rows. + + + onUpdate(config.id, { drop: value })} + /> + + )} + {renderBlockDialog( + config, + open, + categoryOptions, + modelConfigAliases, + modelProviderOptions, + datetimeOptions, + onUpdate, + )} + )} diff --git a/studio/frontend/src/features/recipe-studio/execution-types.ts b/studio/frontend/src/features/recipe-studio/execution-types.ts index 413b3a93f5..dc0c64f879 100644 --- a/studio/frontend/src/features/recipe-studio/execution-types.ts +++ b/studio/frontend/src/features/recipe-studio/execution-types.ts @@ -52,6 +52,8 @@ export type RecipeExecutionRecord = { stage: string | null; // biome-ignore lint/style/useNamingConvention: backend schema current_column: string | null; + // biome-ignore lint/style/useNamingConvention: backend schema + completed_columns: string[]; progress: RecipeExecutionProgress | null; // biome-ignore lint/style/useNamingConvention: backend schema column_progress: RecipeExecutionProgress | null; diff --git a/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts b/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts index f55e85d958..1822ad25c1 100644 --- a/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts +++ b/studio/frontend/src/features/recipe-studio/executions/execution-helpers.ts @@ -138,6 +138,11 @@ export function withExecutionDefaults( datasetTotal, datasetPage, datasetPageSize, + completed_columns: Array.isArray(record.completed_columns) + ? record.completed_columns.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : [], column_progress: record.column_progress ?? null, batch: record.batch ?? null, }; diff --git a/studio/frontend/src/features/recipe-studio/executions/runtime.ts b/studio/frontend/src/features/recipe-studio/executions/runtime.ts index 8d43952eac..fe86499803 100644 --- a/studio/frontend/src/features/recipe-studio/executions/runtime.ts +++ b/studio/frontend/src/features/recipe-studio/executions/runtime.ts @@ -87,6 +87,11 @@ export function applyExecutionStatusSnapshot( rows: status.rows ?? execution.rows, stage: status.stage ?? execution.stage, current_column: status.current_column ?? null, + completed_columns: Array.isArray(status.completed_columns) + ? status.completed_columns.filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ) + : execution.completed_columns, progress: (normalizeObject(status.progress) as RecipeExecutionRecord["progress"]) ?? null, column_progress: (normalizeObject(status.column_progress) as RecipeExecutionRecord["column_progress"]) ?? @@ -123,6 +128,7 @@ export function createBaseExecutionRecord(input: { recipeSignature: input.currentSignature, stage: "pending", current_column: null, + completed_columns: [], progress: null, column_progress: null, batch: null, 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 d4528836c4..3236a6e006 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -12,9 +12,24 @@ import { 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 { HugeiconsIcon } from "@hugeicons/react"; import { @@ -51,6 +66,7 @@ import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions"; import { useRecipeStudioStore } from "./stores/recipe-studio"; import type { LlmType, + NodeConfig, RecipeNode as RecipeBuilderNode, RecipeNodeData, SamplerType, @@ -58,6 +74,10 @@ import type { import type { SeedBlockType } from "./blocks/registry"; import { deriveDisplayGraph } from "./utils/graph/derive-display-graph"; 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"; @@ -73,6 +93,7 @@ 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", @@ -81,6 +102,51 @@ const SUPPORTED_DRAG_KINDS: RecipeBlockDragPayload["kind"][] = [ "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 { @@ -163,6 +229,7 @@ export function RecipeStudioPage({ setLayoutDirection, applyLayout, setAuxNodePosition, + setExecutionLocked, } = useRecipeStudioStore( useShallow((state) => ({ nodes: state.nodes, @@ -198,6 +265,7 @@ export function RecipeStudioPage({ setLayoutDirection: state.setLayoutDirection, applyLayout: state.applyLayout, setAuxNodePosition: state.setAuxNodePosition, + setExecutionLocked: state.setExecutionLocked, })), ); const [sheetContainer, setSheetContainer] = useState( @@ -228,28 +296,6 @@ export function RecipeStudioPage({ [edges], ); - const displayGraph = useMemo(() => { - return deriveDisplayGraph({ - nodes, - edges, - configs, - layoutDirection, - auxNodePositions, - llmAuxVisibility, - }); - }, [ - auxNodePositions, - configs, - edges, - layoutDirection, - llmAuxVisibility, - nodes, - ]); - const displayNodeIds = useMemo( - () => displayGraph.nodes.map((node) => node.id), - [displayGraph.nodes], - ); - const handleNodeClick = useCallback( (_: unknown, node: Node) => { if (node.type !== "builder") { @@ -418,10 +464,6 @@ export function RecipeStudioPage({ setLayoutDirection(layoutDirection === "LR" ? "TB" : "LR"); }, [layoutDirection, setLayoutDirection]); - const toggleInteractive = useCallback(() => { - setInteractive((value) => !value); - }, []); - const payloadResult = useMemo( () => buildRecipePayload( @@ -494,6 +536,73 @@ 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 executionLocked = runtimeVisualState.executionLocked; + const canvasInteractive = interactive && !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) { + return; + } + setInteractive((value) => !value); + }, [executionLocked]); + + useEffect(() => { + setExecutionLocked(executionLocked); + }, [executionLocked, setExecutionLocked]); const openProcessorsFromSheet = useCallback(() => { if ( @@ -584,9 +693,9 @@ export function RecipeStudioPage({ onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} isValidConnection={isValidConnection} - nodesDraggable={interactive} - nodesConnectable={interactive} - elementsSelectable={interactive} + nodesDraggable={canvasInteractive} + nodesConnectable={canvasInteractive} + elementsSelectable={canvasInteractive} fitView={false} className="h-full w-full rounded-t-none" > @@ -647,19 +756,38 @@ export function RecipeStudioPage({ /> + {runtimeVisualState.batch && ( + + + + Batch {runtimeVisualState.batch.idx ?? "--"}/ + {runtimeVisualState.batch.total} + + {activeExecution?.current_column && ( + + + Column: {activeExecution.current_column} + + )} + + + )} openRunDialog(runDialogKind)} - disabled={previewLoading || fullLoading} + disabled={previewLoading || fullLoading || executionLocked} > - {previewLoading || fullLoading ? "Running..." : "Run"} + {previewLoading || fullLoading || executionLocked + ? "Running..." + : "Run"} {validateLoading ? "Validating..." : "Validate"} @@ -698,6 +826,7 @@ export function RecipeStudioPage({ open={dialogOpen} onOpenChange={setDialogOpen} config={config} + readOnly={executionLocked} categoryOptions={dialogOptions.categoryOptions} modelConfigAliases={dialogOptions.modelConfigAliases} modelProviderOptions={dialogOptions.modelProviderOptions} diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts index cae9d42703..96e4bbb2c6 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -67,12 +67,14 @@ type RecipeStudioState = { activeConfigId: string | null; dialogOpen: boolean; layoutDirection: LayoutDirection; + executionLocked: boolean; nextId: number; nextY: number; fitViewTick: number; setSheetView: (view: SheetView) => void; setProcessors: (processors: RecipeProcessorConfig[]) => void; setDialogOpen: (open: boolean) => void; + setExecutionLocked: (locked: boolean) => void; resetRecipe: () => void; selectConfig: (id: string) => void; openConfig: (id: string) => void; @@ -114,6 +116,7 @@ const INITIAL_STATE = { activeConfigId: null, dialogOpen: false, layoutDirection: "LR", + executionLocked: false, nextId: 3, nextY: 280, fitViewTick: 0, @@ -129,6 +132,7 @@ const INITIAL_STATE = { | "activeConfigId" | "dialogOpen" | "layoutDirection" + | "executionLocked" | "nextId" | "nextY" | "fitViewTick" @@ -244,35 +248,45 @@ function isModelSemanticEdge(edge: Edge, configs: Record): b export const useRecipeStudioStore = create((set, get) => ({ ...INITIAL_STATE, setSheetView: (view) => set({ sheetView: view }), - setProcessors: (processors) => set({ processors }), + setProcessors: (processors) => + set((state) => (state.executionLocked ? state : { processors })), setDialogOpen: (open) => set({ dialogOpen: open }), + setExecutionLocked: (locked) => set({ executionLocked: locked }), resetRecipe: () => set(INITIAL_STATE), selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }), openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }), setLayoutDirection: (direction) => - set((state) => ({ - layoutDirection: direction, - edges: state.edges.map((edge) => { - if (isModelSemanticEdge(edge, state.configs)) { + set((state) => { + if (state.executionLocked) { + return state; + } + return { + layoutDirection: direction, + edges: state.edges.map((edge) => { + if (isModelSemanticEdge(edge, state.configs)) { + return { + ...edge, + sourceHandle: normalizeRecipeHandleId(edge.sourceHandle), + targetHandle: normalizeRecipeHandleId(edge.targetHandle), + }; + } return { ...edge, - sourceHandle: normalizeRecipeHandleId(edge.sourceHandle), - targetHandle: normalizeRecipeHandleId(edge.targetHandle), + ...remapRecipeEdgeHandlesForLayout(edge, direction), }; - } - return { - ...edge, - ...remapRecipeEdgeHandlesForLayout(edge, direction), - }; - }), - nodes: applyLayoutDirectionToNodes( - state.nodes, - state.configs, - direction, - ), - })), + }), + nodes: applyLayoutDirectionToNodes( + state.nodes, + state.configs, + direction, + ), + }; + }), applyLayout: () => set((state) => { + if (state.executionLocked) { + return state; + } const isTopBottom = state.layoutDirection === "TB"; const displayGraph = deriveDisplayGraph({ @@ -333,11 +347,17 @@ export const useRecipeStudioStore = create((set, get) => ({ }; }), addSamplerNode: (type, position, openDialog = true) => - set((state) => - buildAddedNodeState(state, "sampler", type, position, openDialog), - ), + set((state) => { + if (state.executionLocked) { + return state; + } + return buildAddedNodeState(state, "sampler", type, position, openDialog); + }), addSeedNode: (type, position, openDialog = true) => set((state) => { + if (state.executionLocked) { + return state; + } const existing = Object.values(state.configs).find( (config) => config.kind === "seed", ); @@ -390,11 +410,17 @@ export const useRecipeStudioStore = create((set, get) => ({ }; }), addLlmNode: (type, position, openDialog = true) => - set((state) => - buildAddedNodeState(state, "llm", type, position, openDialog), - ), + set((state) => { + if (state.executionLocked) { + return state; + } + return buildAddedNodeState(state, "llm", type, position, openDialog); + }), addModelProviderNode: (position, openDialog = true) => set((state) => { + if (state.executionLocked) { + return state; + } const added = buildAddedNodeState( state, "llm", @@ -436,6 +462,9 @@ export const useRecipeStudioStore = create((set, get) => ({ }), addModelConfigNode: (position, openDialog = true) => set((state) => { + if (state.executionLocked) { + return state; + } const added = buildAddedNodeState( state, "llm", @@ -495,25 +524,31 @@ export const useRecipeStudioStore = create((set, get) => ({ return { ...added, nodes, edges, configs }; }), addExpressionNode: (position, openDialog = true) => - set((state) => - buildAddedNodeState( + set((state) => { + if (state.executionLocked) { + return state; + } + return buildAddedNodeState( state, "expression", "expression", position, openDialog, - ), - ), + ); + }), addMarkdownNoteNode: (position, openDialog = true) => - set((state) => - buildAddedNodeState( + set((state) => { + if (state.executionLocked) { + return state; + } + return buildAddedNodeState( state, "note", "markdown_note", position, openDialog, - ), - ), + ); + }), loadRecipe: (snapshot) => set((state) => ({ configs: snapshot.configs, @@ -549,6 +584,9 @@ export const useRecipeStudioStore = create((set, get) => ({ }), updateConfig: (id, patch) => { const applyUpdate = (state: RecipeStudioState) => { + if (state.executionLocked) { + return state; + } const current = state.configs[id]; if (!current) { return state; @@ -587,6 +625,9 @@ export const useRecipeStudioStore = create((set, get) => ({ }, onNodesChange: (changes) => { const applyNodesChange = (state: RecipeStudioState) => { + if (state.executionLocked) { + return state; + } const removedIds = changes .filter((change) => change.type === "remove") .map((change) => change.id); @@ -615,6 +656,9 @@ export const useRecipeStudioStore = create((set, get) => ({ }, onEdgesChange: (changes) => { set((state) => { + if (state.executionLocked) { + return state; + } const removedEdges = changes .filter((change) => change.type === "remove") .map((change) => state.edges.find((edge) => edge.id === change.id)) @@ -628,6 +672,9 @@ export const useRecipeStudioStore = create((set, get) => ({ }, onConnect: (connection) => { set((state) => { + if (state.executionLocked) { + return state; + } const result = applyRecipeConnection( connection, state.configs, diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts index ec61d3aaa5..7d36de28cd 100644 --- a/studio/frontend/src/features/recipe-studio/types/index.ts +++ b/studio/frontend/src/features/recipe-studio/types/index.ts @@ -43,6 +43,8 @@ export type RecipeNodeData = { | "model_provider" | "model_config"; layoutDirection?: LayoutDirection; + runtimeState?: "idle" | "running" | "done"; + executionLocked?: boolean; }; export type RecipeNode = Node; diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts index ef7cb62661..a1560408d3 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts @@ -24,6 +24,12 @@ type DisplayGraphInput = { layoutDirection: LayoutDirection; auxNodePositions: Record; llmAuxVisibility: Record; + runtime?: { + runningNodeId: string | null; + doneNodeIds: Set; + activeEdgeIds: Set; + executionLocked: boolean; + }; }; export type DisplayGraph = { @@ -35,15 +41,16 @@ function normalizeEdge( edge: Edge, configs: Record, layoutDirection: LayoutDirection, + activeEdgeIds: Set, ): Edge { - const baseStyle = { stroke: "var(--foreground)", strokeWidth: 2 }; + const isActiveEdge = activeEdgeIds.has(edge.id); const isAux = edge.source.startsWith("aux-") || edge.target.startsWith("aux-"); if (isAux) { return { ...edge, type: "canvas", - data: { ...(edge.data ?? {}), path: "smoothstep" }, - style: { ...baseStyle, ...(edge.style ?? {}) }, + data: { ...(edge.data ?? {}), path: "smoothstep", active: isActiveEdge }, + animated: isActiveEdge, }; } @@ -86,10 +93,12 @@ function normalizeEdge( return { ...edge, type: semantic ? "semantic" : "canvas", - data: semantic ? edge.data : { ...(edge.data ?? {}), path: "smoothstep" }, + data: semantic + ? { ...(edge.data ?? {}), active: isActiveEdge } + : { ...(edge.data ?? {}), path: "smoothstep", active: isActiveEdge }, sourceHandle, targetHandle, - style: { ...baseStyle, ...(edge.style ?? {}) }, + animated: isActiveEdge, }; } @@ -361,18 +370,41 @@ export function deriveDisplayGraph({ layoutDirection, auxNodePositions, llmAuxVisibility, + runtime, }: DisplayGraphInput): DisplayGraph { + const executionLocked = runtime?.executionLocked ?? false; + const runningNodeId = runtime?.runningNodeId ?? null; + const doneNodeIds = runtime?.doneNodeIds ?? new Set(); + const activeEdgeIds = runtime?.activeEdgeIds ?? new Set(); const displayNodes = nodes.map((node) => { const hasWidth = typeof node.width === "number" || typeof node.style?.width === "number" || (typeof node.style?.width === "string" && Number.isFinite(Number.parseFloat(node.style.width))); + const runtimeState: "idle" | "running" | "done" = + node.id === runningNodeId + ? "running" + : doneNodeIds.has(node.id) + ? "done" + : "idle"; if (hasWidth) { - return node; + return { + ...node, + data: { + ...node.data, + runtimeState, + executionLocked, + }, + }; } return { ...node, + data: { + ...node.data, + runtimeState, + executionLocked, + }, style: { ...node.style, width: DEFAULT_NODE_WIDTH }, }; }); @@ -407,6 +439,7 @@ export function deriveDisplayGraph({ llmId: config.id, field: "system_prompt", title: "System Prompt", + executionLocked, }, }); } @@ -419,6 +452,7 @@ export function deriveDisplayGraph({ llmId: config.id, field: "prompt", title: "Prompt", + executionLocked, }, }); } @@ -431,6 +465,7 @@ export function deriveDisplayGraph({ kind: "llm-judge-score", llmId: config.id, scoreIndex, + executionLocked, }, }); }); @@ -537,7 +572,7 @@ export function deriveDisplayGraph({ return { nodes: [...displayNodes, ...auxNodes], edges: [...edges, ...auxEdges].map((edge) => - normalizeEdge(edge, configs, layoutDirection), + normalizeEdge(edge, configs, layoutDirection, activeEdgeIds), ), }; } diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts b/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts new file mode 100644 index 0000000000..b14e735a85 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts @@ -0,0 +1,157 @@ +import type { Edge } from "@xyflow/react"; +import type { + RecipeExecutionBatch, + RecipeExecutionRecord, + RecipeExecutionStatus, +} from "../../execution-types"; +import type { NodeConfig } from "../../types"; + +const ACTIVE_STATUSES: ReadonlySet = new Set([ + "pending", + "running", + "active", + "cancelling", +]); + +export type GraphRuntimeVisualState = { + executionLocked: boolean; + runningNodeId: string | null; + doneNodeIds: Set; + activeEdgeIds: Set; + batch: RecipeExecutionBatch | null; +}; + +export function pickLatestActiveExecution( + executions: RecipeExecutionRecord[], +): RecipeExecutionRecord | null { + for (const execution of executions) { + if (ACTIVE_STATUSES.has(execution.status)) { + return execution; + } + } + return null; +} + +export function deriveGraphRuntimeVisualState(input: { + activeExecution: RecipeExecutionRecord | null; + configs: Record; + edges: Edge[]; +}): GraphRuntimeVisualState { + const { activeExecution, configs, edges } = input; + if (!activeExecution) { + return { + executionLocked: false, + runningNodeId: null, + doneNodeIds: new Set(), + activeEdgeIds: new Set(), + batch: null, + }; + } + + const nameToNodeId = new Map(); + for (const config of Object.values(configs)) { + const name = config.name.trim(); + if (!name) { + continue; + } + nameToNodeId.set(name, config.id); + } + + const doneNodeIds = new Set(); + for (const columnName of activeExecution.completed_columns) { + const nodeId = nameToNodeId.get(columnName.trim()); + if (nodeId) { + doneNodeIds.add(nodeId); + } + } + + const runningNodeId = activeExecution.current_column + ? nameToNodeId.get(activeExecution.current_column.trim()) ?? null + : null; + if (runningNodeId) { + doneNodeIds.delete(runningNodeId); + } + + const activeEdgeIds = new Set(); + if (runningNodeId) { + for (const upstreamNodeId of collectUpstreamDoneNodeIds({ + rootNodeId: runningNodeId, + edges, + configs, + })) { + doneNodeIds.add(upstreamNodeId); + } + for (const edge of edges) { + if (edge.target !== runningNodeId) { + continue; + } + if (edge.source.startsWith("aux-") || edge.target.startsWith("aux-")) { + continue; + } + activeEdgeIds.add(edge.id); + } + } + + const batch = + activeExecution.batch && + typeof activeExecution.batch.total === "number" && + activeExecution.batch.total > 1 + ? activeExecution.batch + : null; + + return { + executionLocked: true, + runningNodeId, + doneNodeIds, + activeEdgeIds, + batch, + }; +} + +function collectUpstreamDoneNodeIds(input: { + rootNodeId: string; + edges: Edge[]; + configs: Record; +}): Set { + const { rootNodeId, edges, configs } = input; + const doneKinds = new Set([ + "sampler", + "seed", + "expression", + "llm", + "model_config", + "model_provider", + ]); + const incoming = new Map(); + for (const edge of edges) { + if (edge.source.startsWith("aux-") || edge.target.startsWith("aux-")) { + continue; + } + const list = incoming.get(edge.target) ?? []; + list.push(edge.source); + incoming.set(edge.target, list); + } + + const visited = new Set(); + const queue = [rootNodeId]; + const doneNodeIds = new Set(); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current)) { + continue; + } + visited.add(current); + const sources = incoming.get(current) ?? []; + for (const sourceId of sources) { + if (!visited.has(sourceId)) { + queue.push(sourceId); + } + const config = configs[sourceId]; + if (config && doneKinds.has(config.kind)) { + doneNodeIds.add(sourceId); + } + } + } + + return doneNodeIds; +} diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 4ed13bdb57..74ec2e0816 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -320,6 +320,7 @@ } } + /* Minimal scrollbar — thumb only, no track */ * { scrollbar-width: thin;
Drop from final dataset
- Keep for generation but omit from exported rows. -
+ Keep for generation but omit from exported rows. +
+ Batch {runtimeVisualState.batch.idx ?? "--"}/ + {runtimeVisualState.batch.total} +
Column: {activeExecution.current_column}