From 7ed6ad1e0c5ed8c98eaa0119bbc502124855eb89 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Thu, 26 Feb 2026 14:27:36 +0100 Subject: [PATCH 01/41] fix(recipe-studio): support user.* refs validation + toggle user badge details; style user refs/node amber --- .../components/recipe-graph-node.tsx | 10 ++- .../dialogs/shared/available-variables.tsx | 79 +++++++++++++++---- .../src/features/recipe-studio/utils/refs.ts | 15 +++- 3 files changed, 88 insertions(+), 16 deletions(-) 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 4d1bbeedc5..0c046659e5 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 @@ -97,6 +97,8 @@ const NODE_META = { tone: "bg-orange-50 text-orange-600 border-orange-100", }, } as const; +const USER_NODE_TONE = + "bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-900/60"; const SAMPLER_ICONS: Record = { category: Tag01Icon, @@ -342,6 +344,12 @@ function RecipeGraphNodeBase({ (Boolean(config.prompt.trim()) || Boolean(config.system_prompt.trim()) || Boolean((config.scores?.length ?? 0) > 0)); + const iconTone = + config?.kind === "sampler" && + (config.sampler_type === "person" || + config.sampler_type === "person_from_faker") + ? USER_NODE_TONE + : meta.tone; return ( @@ -362,7 +370,7 @@ function RecipeGraphNodeBase({
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/shared/available-variables.tsx b/studio/frontend/src/features/recipe-studio/dialogs/shared/available-variables.tsx index dea66a1442..b726541d53 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/shared/available-variables.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/shared/available-variables.tsx @@ -1,5 +1,5 @@ import { Badge } from "@/components/ui/badge"; -import type { ReactElement } from "react"; +import { type ReactElement, useMemo, useState } from "react"; import { useRecipeStudioStore } from "../../stores/recipe-studio"; import { getAvailableVariableEntries } from "../../utils/variables"; @@ -7,11 +7,33 @@ type AvailableVariablesProps = { configId: string; }; +const USER_EXPANDED_FIELDS = [ + "first_name", + "last_name", + "sex", + "city", + "state", + "age", +] as const; +const USER_BADGE_CLASS = + "corner-squircle border-amber-500/25 bg-amber-500/10 font-mono text-[11px] text-amber-700 dark:text-amber-300"; + export function AvailableVariables({ configId, }: AvailableVariablesProps): ReactElement | null { + const [showUserFields, setShowUserFields] = useState(false); const configs = useRecipeStudioStore((state) => state.configs); const vars = getAvailableVariableEntries(configs, configId); + const variableNames = useMemo(() => new Set(vars.map((entry) => entry.name)), [vars]); + const hasUserRoot = variableNames.has("user"); + const userFieldEntries = useMemo( + () => + USER_EXPANDED_FIELDS.map((field) => ({ + source: "column" as const, + name: `user.${field}`, + })).filter((entry) => !variableNames.has(entry.name)), + [variableNames], + ); if (vars.length === 0) return null; @@ -21,19 +43,48 @@ export function AvailableVariables({ Available references

- {vars.map((v) => ( - - {`{{ ${v.name} }}`} - - ))} + {vars.map((v) => { + const className = + v.name === "user" || v.name.startsWith("user.") + ? USER_BADGE_CLASS + : v.source === "seed" + ? "corner-squircle border-blue-500/25 bg-blue-500/10 font-mono text-[11px] text-blue-700 dark:text-blue-300" + : "corner-squircle font-mono text-[11px]"; + if (v.name !== "user") { + return ( + + {`{{ ${v.name} }}`} + + ); + } + return ( + + ); + })} + {hasUserRoot && showUserFields && + userFieldEntries.map((entry) => ( + + {`{{ ${entry.name} }}`} + + ))}
); diff --git a/studio/frontend/src/features/recipe-studio/utils/refs.ts b/studio/frontend/src/features/recipe-studio/utils/refs.ts index 50f6c5b25f..50c5e3d7f7 100644 --- a/studio/frontend/src/features/recipe-studio/utils/refs.ts +++ b/studio/frontend/src/features/recipe-studio/utils/refs.ts @@ -2,6 +2,19 @@ const JINJA_REF_RE = /{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}/g; const JINJA_EXPR_RE = /{{\s*([^{}]+?)\s*}}/g; const SIMPLE_JINJA_EXPR_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/; const PLAIN_JINJA_EXPR_RE = /^[a-zA-Z0-9_.\s-]+$/; +const NESTED_REFERENCE_ROOTS = new Set(["user"]); + +function isValidNestedReference(expr: string, validSet: Set): boolean { + if (!expr.includes(".")) { + return false; + } + const parts = expr.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length < 2) { + return false; + } + const root = parts[0]; + return validSet.has(root) && NESTED_REFERENCE_ROOTS.has(root); +} function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -38,7 +51,7 @@ export function findInvalidJinjaReferences( continue; } if (SIMPLE_JINJA_EXPR_RE.test(expr)) { - if (!validSet.has(expr)) { + if (!validSet.has(expr) && !isValidNestedReference(expr, validSet)) { invalid.add(expr); } continue; From 8a996afbfba9df84ddd0679455a3b04feb3b35be Mon Sep 17 00:00:00 2001 From: Shine1i Date: Thu, 26 Feb 2026 15:27:46 +0100 Subject: [PATCH 02/41] feat(recipe-studio): add live execution graph state (active flows, node status, editor lock) p1 --- .gitignore | 1 + .../backend/core/data_recipe/jobs/manager.py | 1 + studio/backend/core/data_recipe/jobs/parse.py | 9 + studio/backend/core/data_recipe/jobs/types.py | 1 + .../src/features/recipe-studio/api/index.ts | 2 + .../components/controls/viewport-controls.tsx | 3 + .../components/recipe-graph-aux-node.tsx | 27 ++- .../components/recipe-graph-node.tsx | 42 +++- .../components/recipe-graph-semantic-edge.tsx | 12 +- .../components/rf-ui/data-edge.tsx | 18 +- .../recipe-studio/dialogs/config-dialog.tsx | 54 +++-- .../features/recipe-studio/execution-types.ts | 2 + .../executions/execution-helpers.ts | 5 + .../recipe-studio/executions/runtime.ts | 6 + .../recipe-studio/recipe-studio-page.tsx | 195 +++++++++++++++--- .../recipe-studio/stores/recipe-studio.ts | 113 +++++++--- .../src/features/recipe-studio/types/index.ts | 2 + .../utils/graph/derive-display-graph.ts | 49 ++++- .../utils/graph/runtime-visual-state.ts | 157 ++++++++++++++ studio/frontend/src/index.css | 1 + 20 files changed, 584 insertions(+), 116 deletions(-) create mode 100644 studio/frontend/src/features/recipe-studio/utils/graph/runtime-visual-state.ts 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}`} - @@ -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 })} />