feat(recipe-studio): add live execution graph state (active flows, node status, editor lock) p1

This commit is contained in:
Shine1i 2026-02-26 15:27:46 +01:00
commit 8a996afbfb
20 changed files with 584 additions and 116 deletions

1
.gitignore vendored
View file

@ -21,6 +21,7 @@ unsloth_compiled_cache/
outputs/
exports/
/datasets/
studio/backend/assets/datasets/
unsloth_training_checkpoints/
*.gguf
*.safetensors

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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"}
>

View file

@ -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({
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
</BaseNodeHeaderTitle>
<Button type="button" size="xs" variant="ghost" className="nodrag" onClick={removeScore}>
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
disabled={executionLocked}
onClick={removeScore}
>
Remove
</Button>
</BaseNodeHeader>
@ -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 })}
/>
<Textarea
className="corner-squircle nodrag nowheel max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
placeholder="Score description"
value={score.description}
disabled={executionLocked}
onChange={(event) => updateScore({ description: event.target.value })}
/>
<div className="space-y-1">
@ -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
</Button>
</div>
))}
<Button type="button" size="xs" variant="outline" className="nodrag mt-1" onClick={addOption}>
<Button
type="button"
size="xs"
variant="outline"
className="nodrag mt-1"
disabled={executionLocked}
onClick={addOption}
>
Add option
</Button>
</div>

View file

@ -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 (
<BaseNode className="corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm">
<BaseNode
className={cn(
"corner-squircle relative w-full min-w-0 overflow-visible rounded-lg border-border/60 shadow-sm",
runtimeNodeTone,
)}
>
{runtimeState === "running" && config?.kind === "llm" && (
<div className="pointer-events-none absolute -top-7 right-2 z-20">
<span
className="block size-6 animate-spin rounded-full border-[3px] border-primary/90 border-t-transparent bg-background"
aria-label="Running"
/>
</div>
)}
<NodeResizer
isVisible={selected}
minWidth={MIN_NODE_WIDTH}
@ -391,6 +412,7 @@ function RecipeGraphNodeBase({
size="xs"
variant="ghost"
className="nodrag"
disabled={executionLocked}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@ -400,14 +422,15 @@ function RecipeGraphNodeBase({
{llmAuxVisible ? "Hide inputs" : "Show inputs"}
</Button>
)}
<Button
<Button
type="button"
size="xs"
variant="ghost"
className="nodrag"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
className="nodrag"
disabled={executionLocked}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openConfig(id);
}}
>
@ -416,7 +439,12 @@ function RecipeGraphNodeBase({
</div>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<BaseNodeContent
className={cn(
"gap-2 px-3 py-2",
executionLocked && "pointer-events-none opacity-85",
)}
>
{nodeBody}
</BaseNodeContent>

View file

@ -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,
}}
/>

View file

@ -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 (
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={edgeStyle} />
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={edgeStyle}
/>
);
}

View file

@ -18,6 +18,7 @@ type ConfigDialogProps = {
datetimeOptions: string[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => 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 && (
<div className="space-y-4">
<ValidationBanner config={config} />
{showDropToggle && (
<div className="flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div>
<p className="text-sm font-semibold">Drop from final dataset</p>
<p className="text-xs text-muted-foreground">
Keep for generation but omit from exported rows.
</p>
</div>
<Switch
checked={config.drop ?? false}
onCheckedChange={(value) => onUpdate(config.id, { drop: value })}
/>
{readOnly && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Recipe locked while execution is active.
</div>
)}
{renderBlockDialog(
config,
open,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
)}
<ValidationBanner config={config} />
<div className={readOnly ? "pointer-events-none opacity-75" : undefined}>
{showDropToggle && (
<div className="flex items-center corner-squircle justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div>
<p className="text-sm font-semibold">Drop from final dataset</p>
<p className="text-xs text-muted-foreground">
Keep for generation but omit from exported rows.
</p>
</div>
<Switch
checked={config.drop ?? false}
disabled={readOnly}
onCheckedChange={(value) => onUpdate(config.id, { drop: value })}
/>
</div>
)}
{renderBlockDialog(
config,
open,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
)}
</div>
</div>
)}
<DialogFooter>

View file

@ -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;

View file

@ -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,
};

View file

@ -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,

View file

@ -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<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 {
@ -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<HTMLDivElement | null>(
@ -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<RecipeNodeData | RecipeGraphAuxNodeData>) => {
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({
/>
</Panel>
<ViewportControls
interactive={interactive}
interactive={canvasInteractive}
lockDisabled={executionLocked}
onToggleInteractive={toggleInteractive}
/>
{runtimeVisualState.batch && (
<Panel position="top-center" className="m-3">
<div className="rounded-lg border border-border/70 bg-card/95 px-3 py-2 text-xs shadow-sm">
<p className="font-medium text-foreground">
Batch {runtimeVisualState.batch.idx ?? "--"}/
{runtimeVisualState.batch.total}
</p>
{activeExecution?.current_column && (
<div className="mt-0.5 flex items-center gap-1.5 text-muted-foreground">
<HugeiconsIcon icon={currentColumnIcon} className="size-3.5" />
<p>Column: {activeExecution.current_column}</p>
</div>
)}
</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={previewLoading || fullLoading}
disabled={previewLoading || fullLoading || executionLocked}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
{previewLoading || fullLoading ? "Running..." : "Run"}
{previewLoading || fullLoading || executionLocked
? "Running..."
: "Run"}
</Button>
<Button
type="button"
@ -669,7 +797,7 @@ export function RecipeStudioPage({
openRunDialog(runDialogKind);
void validateFromDialog();
}}
disabled={validateLoading}
disabled={validateLoading || executionLocked}
>
<HugeiconsIcon icon={TestTube01Icon} className="size-4" />
{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}

View file

@ -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<string, NodeConfig>): b
export const useRecipeStudioStore = create<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((set, get) => ({
},
onConnect: (connection) => {
set((state) => {
if (state.executionLocked) {
return state;
}
const result = applyRecipeConnection(
connection,
state.configs,

View file

@ -43,6 +43,8 @@ export type RecipeNodeData = {
| "model_provider"
| "model_config";
layoutDirection?: LayoutDirection;
runtimeState?: "idle" | "running" | "done";
executionLocked?: boolean;
};
export type RecipeNode = Node<RecipeNodeData, "builder">;

View file

@ -24,6 +24,12 @@ type DisplayGraphInput = {
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
llmAuxVisibility: Record<string, boolean>;
runtime?: {
runningNodeId: string | null;
doneNodeIds: Set<string>;
activeEdgeIds: Set<string>;
executionLocked: boolean;
};
};
export type DisplayGraph = {
@ -35,15 +41,16 @@ function normalizeEdge(
edge: Edge,
configs: Record<string, NodeConfig>,
layoutDirection: LayoutDirection,
activeEdgeIds: Set<string>,
): 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<string>();
const activeEdgeIds = runtime?.activeEdgeIds ?? new Set<string>();
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),
),
};
}

View file

@ -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<RecipeExecutionStatus> = new Set([
"pending",
"running",
"active",
"cancelling",
]);
export type GraphRuntimeVisualState = {
executionLocked: boolean;
runningNodeId: string | null;
doneNodeIds: Set<string>;
activeEdgeIds: Set<string>;
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<string, NodeConfig>;
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<string, string>();
for (const config of Object.values(configs)) {
const name = config.name.trim();
if (!name) {
continue;
}
nameToNodeId.set(name, config.id);
}
const doneNodeIds = new Set<string>();
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<string>();
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<string, NodeConfig>;
}): Set<string> {
const { rootNodeId, edges, configs } = input;
const doneKinds = new Set<NodeConfig["kind"]>([
"sampler",
"seed",
"expression",
"llm",
"model_config",
"model_provider",
]);
const incoming = new Map<string, string[]>();
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<string>();
const queue = [rootNodeId];
const doneNodeIds = new Set<string>();
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;
}

View file

@ -320,6 +320,7 @@
}
}
/* Minimal scrollbar — thumb only, no track */
* {
scrollbar-width: thin;