feat(recipe-studio): add live execution graph state (active flows, node status, editor lock) p1
This commit is contained in:
parent
7ed6ad1e0c
commit
8a996afbfb
20 changed files with 584 additions and 116 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,6 +21,7 @@ unsloth_compiled_cache/
|
|||
outputs/
|
||||
exports/
|
||||
/datasets/
|
||||
studio/backend/assets/datasets/
|
||||
unsloth_training_checkpoints/
|
||||
*.gguf
|
||||
*.safetensors
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -320,6 +320,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/* Minimal scrollbar — thumb only, no track */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue