diff --git a/studio/frontend/src/features/data-recipes/data/recipes-db.ts b/studio/frontend/src/features/data-recipes/data/recipes-db.ts index 5b7fd0c189..bcc1c13d35 100644 --- a/studio/frontend/src/features/data-recipes/data/recipes-db.ts +++ b/studio/frontend/src/features/data-recipes/data/recipes-db.ts @@ -1,5 +1,6 @@ import Dexie, { type EntityTable, liveQuery } from "dexie"; -import type { RecipePayload } from "@/features/recipe-studio"; +import { createEmptyRecipePayload } from "@/features/recipe-studio"; +import { normalizeNonEmptyName } from "@/utils"; import { useEffect, useState } from "react"; import type { RecipeRecord, SaveRecipeInput } from "../types"; @@ -11,38 +12,6 @@ db.version(1).stores({ recipes: "id, name, updatedAt, createdAt", }); -function normalizeRecipeName(name: string): string { - const trimmed = name.trim(); - return trimmed.length > 0 ? trimmed : "Unnamed"; -} - -function createEmptyPayload(): RecipePayload { - return { - recipe: { - // biome-ignore lint/style/useNamingConvention: api schema - model_providers: [], - // biome-ignore lint/style/useNamingConvention: api schema - mcp_providers: [], - // biome-ignore lint/style/useNamingConvention: api schema - model_configs: [], - // biome-ignore lint/style/useNamingConvention: api schema - tool_configs: [], - columns: [], - processors: [], - }, - run: { - rows: 5, - preview: true, - // biome-ignore lint/style/useNamingConvention: api schema - output_formats: ["jsonl"], - }, - ui: { - nodes: [], - edges: [], - }, - }; -} - export async function listRecipes(): Promise { return db.recipes.orderBy("updatedAt").reverse().toArray(); } @@ -57,7 +26,7 @@ export async function saveRecipe(input: SaveRecipeInput): Promise const existing = input.id ? await db.recipes.get(input.id) : undefined; const record: RecipeRecord = { id, - name: normalizeRecipeName(input.name), + name: normalizeNonEmptyName(input.name), payload: input.payload, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -73,7 +42,7 @@ export async function deleteRecipe(id: string): Promise { export async function createRecipeDraft(): Promise { return saveRecipe({ name: "Unnamed", - payload: createEmptyPayload(), + payload: createEmptyRecipePayload(), }); } diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts new file mode 100644 index 0000000000..e4dbd38e2a --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts @@ -0,0 +1,294 @@ +import { + BalanceScaleIcon, + Clock01Icon, + CodeIcon, + CodeSimpleIcon, + DiceFaces03Icon, + EqualSignIcon, + FingerPrintIcon, + FunctionIcon, + Parabola02Icon, + PencilEdit02Icon, + Plant01Icon, + Shield02Icon, + Tag01Icon, + TagsIcon, + UserAccountIcon, +} from "@hugeicons/core-free-icons"; +import type { LlmType, NodeConfig, SamplerType } from "../types"; +import { + makeExpressionConfig, + makeLlmConfig, + makeModelConfig, + makeModelProviderConfig, + makeSamplerConfig, + makeSeedConfig, +} from "../utils"; + +export type BlockKind = "sampler" | "llm" | "expression" | "seed"; +export type BlockType = + | SamplerType + | LlmType + | "expression" + | "seed" + | "model_provider" + | "model_config"; + +type IconType = typeof CodeIcon; + +export type BlockGroup = { + kind: BlockKind; + title: string; + description: string; + icon: IconType; +}; + +export type BlockDialogKey = + | "seed" + | "category" + | "subcategory" + | "uniform" + | "gaussian" + | "bernoulli" + | "datetime" + | "timedelta" + | "uuid" + | "person" + | "llm" + | "model_provider" + | "model_config" + | "expression"; + +export type BlockDefinition = { + kind: BlockKind; + type: BlockType; + title: string; + description: string; + icon: IconType; + dialogKey: BlockDialogKey; + createConfig: (id: string, existing: NodeConfig[]) => NodeConfig; +}; + +export const BLOCK_GROUPS: BlockGroup[] = [ + { + kind: "sampler", + title: "Sampler", + description: "Numeric + categorical blocks.", + icon: DiceFaces03Icon, + }, + { + kind: "seed", + title: "Seed", + description: "Columns from a seed dataset.", + icon: Plant01Icon, + }, + { + kind: "llm", + title: "LLM", + description: "Text + structured blocks.", + icon: PencilEdit02Icon, + }, + { + kind: "expression", + title: "Expression", + description: "Derived columns with Jinja.", + icon: FunctionIcon, + }, +]; + +const BLOCK_DEFINITIONS: BlockDefinition[] = [ + { + kind: "seed", + type: "seed", + title: "Seed (Hugging Face)", + description: "Configure a HF seed dataset.", + icon: Plant01Icon, + dialogKey: "seed", + createConfig: (id, existing) => makeSeedConfig(id, existing), + }, + { + kind: "sampler", + type: "category", + title: "Category", + description: "Pick from a list of values.", + icon: Tag01Icon, + dialogKey: "category", + createConfig: (id, existing) => makeSamplerConfig(id, "category", existing), + }, + { + kind: "sampler", + type: "subcategory", + title: "Subcategory", + description: "Map sub-values to a category.", + icon: TagsIcon, + dialogKey: "subcategory", + createConfig: (id, existing) => makeSamplerConfig(id, "subcategory", existing), + }, + { + kind: "sampler", + type: "uniform", + title: "Uniform", + description: "Random number between low/high.", + icon: EqualSignIcon, + dialogKey: "uniform", + createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing), + }, + { + kind: "sampler", + type: "gaussian", + title: "Gaussian", + description: "Normal distribution sampler.", + icon: Parabola02Icon, + dialogKey: "gaussian", + createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing), + }, + { + kind: "sampler", + type: "bernoulli", + title: "Bernoulli", + description: "Binary sampler with probability.", + icon: EqualSignIcon, + dialogKey: "bernoulli", + createConfig: (id, existing) => makeSamplerConfig(id, "bernoulli", existing), + }, + { + kind: "sampler", + type: "datetime", + title: "Datetime", + description: "Date/time range sampler.", + icon: Clock01Icon, + dialogKey: "datetime", + createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing), + }, + { + kind: "sampler", + type: "timedelta", + title: "Timedelta", + description: "Offset from datetime column.", + icon: Clock01Icon, + dialogKey: "timedelta", + createConfig: (id, existing) => makeSamplerConfig(id, "timedelta", existing), + }, + { + kind: "sampler", + type: "uuid", + title: "UUID", + description: "UUID string sampler.", + icon: FingerPrintIcon, + dialogKey: "uuid", + createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing), + }, + { + kind: "sampler", + type: "person", + title: "Person", + description: "Synthetic person sampler.", + icon: UserAccountIcon, + dialogKey: "person", + createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), + }, + { + kind: "llm", + type: "text", + title: "LLM Text", + description: "Free-form prompt generation.", + icon: PencilEdit02Icon, + dialogKey: "llm", + createConfig: (id, existing) => makeLlmConfig(id, "text", existing), + }, + { + kind: "llm", + type: "structured", + title: "LLM Structured", + description: "JSON output via schema.", + icon: CodeIcon, + dialogKey: "llm", + createConfig: (id, existing) => makeLlmConfig(id, "structured", existing), + }, + { + kind: "llm", + type: "code", + title: "LLM Code", + description: "Generate code or SQL.", + icon: CodeSimpleIcon, + dialogKey: "llm", + createConfig: (id, existing) => makeLlmConfig(id, "code", existing), + }, + { + kind: "llm", + type: "judge", + title: "LLM Judge", + description: "Score outputs with criteria.", + icon: BalanceScaleIcon, + dialogKey: "llm", + createConfig: (id, existing) => makeLlmConfig(id, "judge", existing), + }, + { + kind: "llm", + type: "model_provider", + title: "Model Provider", + description: "Configure API endpoint + key.", + icon: Shield02Icon, + dialogKey: "model_provider", + createConfig: (id, existing) => makeModelProviderConfig(id, existing), + }, + { + kind: "llm", + type: "model_config", + title: "Model Config", + description: "Alias + model + inference params.", + icon: Plant01Icon, + dialogKey: "model_config", + createConfig: (id, existing) => makeModelConfig(id, existing), + }, + { + kind: "expression", + type: "expression", + title: "Expression", + description: "Transform columns with Jinja.", + icon: FunctionIcon, + dialogKey: "expression", + createConfig: (id, existing) => makeExpressionConfig(id, existing), + }, +]; + +export function getBlocksForKind(kind: BlockKind): BlockDefinition[] { + return BLOCK_DEFINITIONS.filter((block) => block.kind === kind); +} + +export function getBlockDefinition( + kind: BlockKind, + type: BlockType, +): BlockDefinition | null { + return ( + BLOCK_DEFINITIONS.find((block) => block.kind === kind && block.type === type) ?? + null + ); +} + +export function getBlockDefinitionForConfig( + config: NodeConfig | null, +): BlockDefinition | null { + if (!config) { + return null; + } + if (config.kind === "seed") { + return getBlockDefinition("seed", "seed"); + } + if (config.kind === "sampler") { + const samplerType = + config.sampler_type === "person_from_faker" ? "person" : config.sampler_type; + return getBlockDefinition("sampler", samplerType); + } + if (config.kind === "llm") { + return getBlockDefinition("llm", config.llm_type); + } + if (config.kind === "model_provider") { + return getBlockDefinition("llm", "model_provider"); + } + if (config.kind === "model_config") { + return getBlockDefinition("llm", "model_config"); + } + return getBlockDefinition("expression", "expression"); +} + diff --git a/studio/frontend/src/features/recipe-studio/blocks/registry.ts b/studio/frontend/src/features/recipe-studio/blocks/registry.ts new file mode 100644 index 0000000000..c6798c20dc --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/blocks/registry.ts @@ -0,0 +1,15 @@ +export type { + BlockDefinition, + BlockDialogKey, + BlockGroup, + BlockKind, + BlockType, +} from "./definitions"; +export { + BLOCK_GROUPS, + getBlockDefinition, + getBlockDefinitionForConfig, + getBlocksForKind, +} from "./definitions"; +export { renderBlockDialog } from "./render-dialog"; + diff --git a/studio/frontend/src/features/recipe-studio/blocks/registry.tsx b/studio/frontend/src/features/recipe-studio/blocks/registry.tsx deleted file mode 100644 index 1b54f694b0..0000000000 --- a/studio/frontend/src/features/recipe-studio/blocks/registry.tsx +++ /dev/null @@ -1,441 +0,0 @@ -import { - BalanceScaleIcon, - Clock01Icon, - CodeIcon, - CodeSimpleIcon, - DiceFaces03Icon, - EqualSignIcon, - FingerPrintIcon, - FunctionIcon, - Parabola02Icon, - PencilEdit02Icon, - Plant01Icon, - Shield02Icon, - Tag01Icon, - TagsIcon, - UserAccountIcon, -} from "@hugeicons/core-free-icons"; -import type { ReactElement } from "react"; -import type { LlmType, NodeConfig, SamplerConfig, SamplerType } from "../types"; -import { - makeExpressionConfig, - makeLlmConfig, - makeModelConfig, - makeModelProviderConfig, - makeSamplerConfig, - makeSeedConfig, -} from "../utils"; -import { ExpressionDialog } from "../dialogs/expression/expression-dialog"; -import { LlmDialog } from "../dialogs/llm/llm-dialog"; -import { ModelConfigDialog } from "../dialogs/models/model-config-dialog"; -import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog"; -import { SeedDialog } from "../dialogs/seed/seed-dialog"; -import { CategoryDialog } from "../dialogs/samplers/category-dialog"; -import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog"; -import { BernoulliDialog } from "../dialogs/samplers/bernoulli-dialog"; -import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog"; -import { PersonDialog } from "../dialogs/samplers/person-dialog"; -import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog"; -import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog"; -import { UniformDialog } from "../dialogs/samplers/uniform-dialog"; -import { UuidDialog } from "../dialogs/samplers/uuid-dialog"; - -export type BlockKind = "sampler" | "llm" | "expression" | "seed"; -export type BlockType = - | SamplerType - | LlmType - | "expression" - | "seed" - | "model_provider" - | "model_config"; - -type IconType = typeof CodeIcon; - -type BlockGroup = { - kind: BlockKind; - title: string; - description: string; - icon: IconType; -}; - -type BlockDialogArgs = { - config: NodeConfig; - categoryOptions: SamplerConfig[]; - modelConfigAliases: string[]; - modelProviderOptions: string[]; - datetimeOptions: string[]; - onUpdate: (id: string, patch: Partial) => void; -}; - -type BlockDefinition = { - kind: BlockKind; - type: BlockType; - title: string; - description: string; - icon: IconType; - createConfig: (id: string, existing: NodeConfig[]) => NodeConfig; - renderDialog: (args: BlockDialogArgs) => ReactElement | null; -}; - -export const BLOCK_GROUPS: BlockGroup[] = [ - { - kind: "sampler", - title: "Sampler", - description: "Numeric + categorical blocks.", - icon: DiceFaces03Icon, - }, - { - kind: "seed", - title: "Seed", - description: "Columns from a seed dataset.", - icon: Plant01Icon, - }, - { - kind: "llm", - title: "LLM", - description: "Text + structured blocks.", - icon: PencilEdit02Icon, - }, - { - kind: "expression", - title: "Expression", - description: "Derived columns with Jinja.", - icon: FunctionIcon, - }, -]; - -const BLOCK_DEFINITIONS: BlockDefinition[] = [ - { - kind: "seed", - type: "seed", - title: "Seed (Hugging Face)", - description: "Configure a HF seed dataset.", - icon: Plant01Icon, - createConfig: (id, existing) => makeSeedConfig(id, existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "seed" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "category", - title: "Category", - description: "Pick from a list of values.", - icon: Tag01Icon, - createConfig: (id, existing) => makeSamplerConfig(id, "category", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "category" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "subcategory", - title: "Subcategory", - description: "Map sub-values to a category.", - icon: TagsIcon, - createConfig: (id, existing) => - makeSamplerConfig(id, "subcategory", existing), - renderDialog: ({ config, categoryOptions, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "subcategory" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "uniform", - title: "Uniform", - description: "Random number between low/high.", - icon: EqualSignIcon, - createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "uniform" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "gaussian", - title: "Gaussian", - description: "Normal distribution sampler.", - icon: Parabola02Icon, - createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "gaussian" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "bernoulli", - title: "Bernoulli", - description: "Binary sampler with probability.", - icon: EqualSignIcon, - createConfig: (id, existing) => - makeSamplerConfig(id, "bernoulli", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "bernoulli" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "datetime", - title: "Datetime", - description: "Date/time range sampler.", - icon: Clock01Icon, - createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "datetime" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "timedelta", - title: "Timedelta", - description: "Offset from datetime column.", - icon: Clock01Icon, - createConfig: (id, existing) => - makeSamplerConfig(id, "timedelta", existing), - renderDialog: ({ config, datetimeOptions, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "timedelta" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "uuid", - title: "UUID", - description: "UUID string sampler.", - icon: FingerPrintIcon, - createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && config.sampler_type === "uuid" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "sampler", - type: "person", - title: "Person", - description: "Synthetic person sampler.", - icon: UserAccountIcon, - createConfig: (id, existing) => makeSamplerConfig(id, "person", existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "sampler" && - (config.sampler_type === "person" || - config.sampler_type === "person_from_faker") ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "text", - title: "LLM Text", - description: "Free-form prompt generation.", - icon: PencilEdit02Icon, - createConfig: (id, existing) => makeLlmConfig(id, "text", existing), - renderDialog: ({ config, modelConfigAliases, onUpdate }) => - config.kind === "llm" && config.llm_type === "text" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "structured", - title: "LLM Structured", - description: "JSON output via schema.", - icon: CodeIcon, - createConfig: (id, existing) => makeLlmConfig(id, "structured", existing), - renderDialog: ({ config, modelConfigAliases, onUpdate }) => - config.kind === "llm" && config.llm_type === "structured" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "code", - title: "LLM Code", - description: "Generate code or SQL.", - icon: CodeSimpleIcon, - createConfig: (id, existing) => makeLlmConfig(id, "code", existing), - renderDialog: ({ config, modelConfigAliases, onUpdate }) => - config.kind === "llm" && config.llm_type === "code" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "judge", - title: "LLM Judge", - description: "Score outputs with criteria.", - icon: BalanceScaleIcon, - createConfig: (id, existing) => makeLlmConfig(id, "judge", existing), - renderDialog: ({ config, modelConfigAliases, onUpdate }) => - config.kind === "llm" && config.llm_type === "judge" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "model_provider", - title: "Model Provider", - description: "Configure API endpoint + key.", - icon: Shield02Icon, - createConfig: (id, existing) => makeModelProviderConfig(id, existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "model_provider" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "llm", - type: "model_config", - title: "Model Config", - description: "Alias + model + inference params.", - icon: Plant01Icon, - createConfig: (id, existing) => makeModelConfig(id, existing), - renderDialog: ({ config, modelProviderOptions, onUpdate }) => - config.kind === "model_config" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, - { - kind: "expression", - type: "expression", - title: "Expression", - description: "Transform columns with Jinja.", - icon: FunctionIcon, - createConfig: (id, existing) => makeExpressionConfig(id, existing), - renderDialog: ({ config, onUpdate }) => - config.kind === "expression" ? ( - onUpdate(config.id, patch)} - /> - ) : null, - }, -]; - -export function getBlocksForKind(kind: BlockKind): BlockDefinition[] { - return BLOCK_DEFINITIONS.filter((block) => block.kind === kind); -} - -export function getBlockDefinition( - kind: BlockKind, - type: BlockType, -): BlockDefinition | null { - return ( - BLOCK_DEFINITIONS.find( - (block) => block.kind === kind && block.type === type, - ) ?? null - ); -} - -export function getBlockDefinitionForConfig( - config: NodeConfig | null, -): BlockDefinition | null { - if (!config) { - return null; - } - if (config.kind === "seed") { - return getBlockDefinition("seed", "seed"); - } - if (config.kind === "sampler") { - const samplerType = - config.sampler_type === "person_from_faker" - ? "person" - : config.sampler_type; - return getBlockDefinition("sampler", samplerType); - } - if (config.kind === "llm") { - return getBlockDefinition("llm", config.llm_type); - } - if (config.kind === "model_provider") { - return getBlockDefinition("llm", "model_provider"); - } - if (config.kind === "model_config") { - return getBlockDefinition("llm", "model_config"); - } - return getBlockDefinition("expression", "expression"); -} - -export function renderBlockDialog( - config: NodeConfig | null, - categoryOptions: SamplerConfig[], - modelConfigAliases: string[], - modelProviderOptions: string[], - datetimeOptions: string[], - onUpdate: (id: string, patch: Partial) => void, -): ReactElement | null { - const definition = getBlockDefinitionForConfig(config); - if (!definition || !config) { - return null; - } - return definition.renderDialog({ - config, - categoryOptions, - modelConfigAliases, - modelProviderOptions, - datetimeOptions, - onUpdate, - }); -} diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx new file mode 100644 index 0000000000..bcfdd3b0a8 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx @@ -0,0 +1,110 @@ +import type { ReactElement } from "react"; +import type { NodeConfig, SamplerConfig } from "../types"; +import { getBlockDefinitionForConfig } from "./definitions"; +import { ExpressionDialog } from "../dialogs/expression/expression-dialog"; +import { LlmDialog } from "../dialogs/llm/llm-dialog"; +import { ModelConfigDialog } from "../dialogs/models/model-config-dialog"; +import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog"; +import { SeedDialog } from "../dialogs/seed/seed-dialog"; +import { CategoryDialog } from "../dialogs/samplers/category-dialog"; +import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog"; +import { BernoulliDialog } from "../dialogs/samplers/bernoulli-dialog"; +import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog"; +import { PersonDialog } from "../dialogs/samplers/person-dialog"; +import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog"; +import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog"; +import { UniformDialog } from "../dialogs/samplers/uniform-dialog"; +import { UuidDialog } from "../dialogs/samplers/uuid-dialog"; + +export function renderBlockDialog( + config: NodeConfig | null, + categoryOptions: SamplerConfig[], + modelConfigAliases: string[], + modelProviderOptions: string[], + datetimeOptions: string[], + onUpdate: (id: string, patch: Partial) => void, +): ReactElement | null { + const definition = getBlockDefinitionForConfig(config); + if (!definition || !config) { + return null; + } + + const update = (patch: Partial) => onUpdate(config.id, patch); + + switch (definition.dialogKey) { + case "seed": + return config.kind === "seed" ? ( + + ) : null; + case "category": + return config.kind === "sampler" && config.sampler_type === "category" ? ( + + ) : null; + case "subcategory": + return config.kind === "sampler" && config.sampler_type === "subcategory" ? ( + + ) : null; + case "uniform": + return config.kind === "sampler" && config.sampler_type === "uniform" ? ( + + ) : null; + case "gaussian": + return config.kind === "sampler" && config.sampler_type === "gaussian" ? ( + + ) : null; + case "bernoulli": + return config.kind === "sampler" && config.sampler_type === "bernoulli" ? ( + + ) : null; + case "datetime": + return config.kind === "sampler" && config.sampler_type === "datetime" ? ( + + ) : null; + case "timedelta": + return config.kind === "sampler" && config.sampler_type === "timedelta" ? ( + + ) : null; + case "uuid": + return config.kind === "sampler" && config.sampler_type === "uuid" ? ( + + ) : null; + case "person": + return config.kind === "sampler" && + (config.sampler_type === "person" || + config.sampler_type === "person_from_faker") ? ( + + ) : null; + case "llm": + return config.kind === "llm" ? ( + + ) : null; + case "model_provider": + return config.kind === "model_provider" ? ( + + ) : null; + case "model_config": + return config.kind === "model_config" ? ( + + ) : null; + case "expression": + return config.kind === "expression" ? ( + + ) : null; + } +} diff --git a/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx index 5fe6252a4b..5200d02d04 100644 --- a/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx +++ b/studio/frontend/src/features/recipe-studio/components/graph/internals-sync.tsx @@ -1,5 +1,5 @@ import { useUpdateNodeInternals } from "@xyflow/react"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; type InternalsSyncProps = { nodeIds: string[]; @@ -8,16 +8,18 @@ type InternalsSyncProps = { export function InternalsSync({ nodeIds }: InternalsSyncProps): null { const updateNodeInternals = useUpdateNodeInternals(); const idsKey = useMemo(() => nodeIds.join("|"), [nodeIds]); - const stableNodeIds = useMemo(() => nodeIds, [idsKey]); + const nodeIdsRef = useRef(nodeIds); + nodeIdsRef.current = nodeIds; useEffect(() => { if (!idsKey) { return; } - requestAnimationFrame(() => { - updateNodeInternals(stableNodeIds); + const raf = requestAnimationFrame(() => { + updateNodeInternals(nodeIdsRef.current); }); - }, [idsKey, stableNodeIds, updateNodeInternals]); + return () => cancelAnimationFrame(raf); + }, [idsKey, updateNodeInternals]); return null; } diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-category-badges.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-category-badges.tsx index 0302c2e7a3..f7eca468eb 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-category-badges.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-category-badges.tsx @@ -1,5 +1,5 @@ -import { Badge } from "@/components/ui/badge"; -import { type ReactElement, useLayoutEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { type ReactElement, useLayoutEffect, useRef, useState } from "react"; type InlineCategoryBadgesProps = { values: string[]; @@ -11,15 +11,15 @@ export function InlineCategoryBadges({ const containerRef = useRef(null); const [visibleCount, setVisibleCount] = useState(values.length); - useLayoutEffect(() => { - const container = containerRef.current; - if (!container) return; - - const badges = Array.from(container.children) as HTMLElement[]; - if (badges.length === 0) { - setVisibleCount(0); - return; - } + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const badges = Array.from(container.children) as HTMLElement[]; + if (badges.length === 0) { + const id = requestAnimationFrame(() => setVisibleCount(0)); + return () => cancelAnimationFrame(id); + } const containerWidth = container.clientWidth; // Reserve space for the "+N" badge (~36px) @@ -39,8 +39,9 @@ export function InlineCategoryBadges({ count++; } - setVisibleCount(count || 1); - }, [values]); + const id = requestAnimationFrame(() => setVisibleCount(count || 1)); + return () => cancelAnimationFrame(id); + }, [values]); if (values.length === 0) { return

No values

; diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx index 97644e561f..6a269ca684 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-llm.tsx @@ -13,7 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { type ReactElement, useEffect, useMemo, useRef, useState } from "react"; +import { type ReactElement, useMemo, useRef } from "react"; import { useRecipeStudioStore } from "../../stores/recipe-studio"; import type { LlmConfig } from "../../types"; import { InlineField } from "./inline-field"; @@ -52,12 +52,13 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement { .map((c) => c.name), [configs], ); - const [aliasInput, setAliasInput] = useState(config.model_alias); + const aliasInputRef = useRef(config.model_alias); + const lastAliasRef = useRef(config.model_alias); const anchorRef = useRef(null); - - useEffect(() => { - setAliasInput(config.model_alias); - }, [config.model_alias]); + if (lastAliasRef.current !== config.model_alias) { + lastAliasRef.current = config.model_alias; + aliasInputRef.current = config.model_alias; + } return (
@@ -74,7 +75,9 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement { model_alias: value ?? "", }) } - onInputValueChange={setAliasInput} + onInputValueChange={(value) => { + aliasInputRef.current = value; + }} itemToStringValue={(value) => value} autoHighlight={true} > @@ -82,10 +85,11 @@ export function InlineLlm({ config, onUpdate }: InlineLlmProps): ReactElement { className="nodrag h-8 w-full text-xs" placeholder="Model alias" onBlur={() => { - if (aliasInput !== config.model_alias) { + const next = aliasInputRef.current; + if (next !== config.model_alias) { onUpdate({ // biome-ignore lint/style/useNamingConvention: api schema - model_alias: aliasInput, + model_alias: next, }); } }} diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx index c040954d05..970b433929 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx @@ -8,7 +8,7 @@ import { ComboboxList, } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; -import { type ReactElement, useEffect, useRef, useState } from "react"; +import { type ReactElement, useRef } from "react"; import type { ModelConfig } from "../../types"; import { NameField } from "../shared/name-field"; @@ -29,10 +29,12 @@ export function ModelConfigDialog({ const topPId = `${config.id}-top-p`; const maxTokensId = `${config.id}-max-tokens`; const providerAnchorRef = useRef(null); - const [providerInput, setProviderInput] = useState(config.provider); - useEffect(() => { - setProviderInput(config.provider); - }, [config.provider]); + const providerInputRef = useRef(config.provider); + const lastProviderRef = useRef(config.provider); + if (lastProviderRef.current !== config.provider) { + lastProviderRef.current = config.provider; + providerInputRef.current = config.provider; + } const updateField = ( key: K, value: ModelConfig[K], @@ -75,7 +77,9 @@ export function ModelConfigDialog({ filter={null} value={config.provider || null} onValueChange={(value) => updateField("provider", value ?? "")} - onInputValueChange={setProviderInput} + onInputValueChange={(value) => { + providerInputRef.current = value; + }} itemToStringValue={(value) => value} autoHighlight={true} > @@ -84,8 +88,9 @@ export function ModelConfigDialog({ className="nodrag w-full" placeholder="Pick provider or type name" onBlur={() => { - if (providerInput !== config.provider) { - updateField("provider", providerInput); + const next = providerInputRef.current; + if (next !== config.provider) { + updateField("provider", next); } }} /> diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts index ecbbcb498d..8331aa1bd9 100644 --- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts +++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-studio-actions.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toastError, toastSuccess } from "@/shared/toast"; +import { normalizeNonEmptyName } from "@/utils"; import { previewRecipe, validateRecipe } from "../api"; import { importRecipePayload, type RecipeSnapshot } from "../utils/import"; import type { RecipePayload, RecipePayloadResult } from "../utils/payload/types"; @@ -53,11 +54,6 @@ function buildSignature(name: string, payload: RecipePayload): string { return JSON.stringify({ name, payload }); } -function normalizeWorkflowName(value: string): string { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : "Unnamed"; -} - function formatSavedLabel(savedAt: number | null): string { if (!savedAt) { return "Not saved yet"; @@ -126,7 +122,7 @@ export function useRecipeStudioActions({ const [previewLoading, setPreviewLoading] = useState(false); const normalizedWorkflowName = useMemo( - () => normalizeWorkflowName(workflowName), + () => normalizeNonEmptyName(workflowName, "Unnamed"), [workflowName], ); const currentPayload = payloadResult.payload; @@ -140,7 +136,7 @@ export function useRecipeStudioActions({ const payloadErrorMessage = payloadResult.errors[0] ?? "Invalid payload."; useEffect(() => { - const nextName = normalizeWorkflowName(initialRecipeName); + const nextName = normalizeNonEmptyName(initialRecipeName, "Unnamed"); resetRecipe(); setWorkflowName(nextName); setLastSavedAt(initialSavedAt); @@ -171,7 +167,7 @@ export function useRecipeStudioActions({ if (saveLoading) { return; } - const nextName = normalizeWorkflowName(workflowName); + const nextName = normalizeNonEmptyName(workflowName, "Unnamed"); if (nextName !== workflowName) { setWorkflowName(nextName); } diff --git a/studio/frontend/src/features/recipe-studio/index.ts b/studio/frontend/src/features/recipe-studio/index.ts index fe02998475..a38b4ed210 100644 --- a/studio/frontend/src/features/recipe-studio/index.ts +++ b/studio/frontend/src/features/recipe-studio/index.ts @@ -5,3 +5,4 @@ export type { RecipeStudioPageProps, } from "./recipe-studio-page"; export type { RecipePayload } from "./utils/payload/types"; +export { createEmptyRecipePayload } from "./utils/payload/empty"; 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 951387e43c..8abc9b56fa 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -42,6 +42,11 @@ import { deriveDisplayGraph } from "./utils/graph/derive-display-graph"; import { buildRecipePayload } from "./utils/payload"; import type { RecipePayload } from "./utils/payload/types"; import { buildDefaultSchemaTransform } from "./utils/processors"; +import { + applyAuxNodeChanges, + filterEdgeChangesByIds, + filterNodeChangesByIds, +} from "./utils/reactflow-changes"; import { buildDialogOptions, buildPreviewSummary, @@ -197,32 +202,12 @@ export function RecipeStudioPage({ const handleNodesChange = useCallback( (changes: NodeChange>[]) => { - for (const change of changes) { - if (!("id" in change) || !change.id.startsWith("aux-")) { - continue; - } - if (change.type === "position") { - const nextPosition = change.position ?? change.positionAbsolute; - if (nextPosition) setAuxNodePosition(change.id, nextPosition); - continue; - } - if ( - change.type === "dimensions" && - change.dimensions && - change.dimensions.width > 0 && - change.dimensions.height > 0 - ) { - setAuxNodeSize(change.id, { - width: change.dimensions.width, - height: change.dimensions.height, - }); - } - } - const next = changes.filter( - (change): change is NodeChange => - "id" in change && baseNodeIds.has(change.id), + applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize }); + const next = filterNodeChangesByIds( + changes as NodeChange[], + baseNodeIds, ); - if (next.length > 0) { + if (next.length) { onNodesChange(next); } }, @@ -231,11 +216,8 @@ export function RecipeStudioPage({ const handleEdgesChange = useCallback( (changes: EdgeChange[]) => { - const next = changes.filter( - (change): change is EdgeChange => - "id" in change && baseEdgeIds.has(change.id), - ); - if (next.length > 0) { + const next = filterEdgeChangesByIds(changes, baseEdgeIds); + if (next.length) { onEdgesChange(next); } }, diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts new file mode 100644 index 0000000000..109a950b43 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/aux-sync.ts @@ -0,0 +1,62 @@ +import type { XYPosition } from "@xyflow/react"; + +export function syncPositionsRecord( + prev: Record, + activeIds: string[], + defaults: Record, +): Record { + const next: Record = {}; + for (const id of activeIds) { + const existing = prev[id]; + if (existing) { + next[id] = existing; + continue; + } + const fallback = defaults[id]; + if (fallback) { + next[id] = fallback; + } + } + + const prevIds = Object.keys(prev); + const nextIds = Object.keys(next); + if (prevIds.length !== nextIds.length) { + return next; + } + for (const id of nextIds) { + const a = prev[id]; + const b = next[id]; + if (!(a && b && a.x === b.x && a.y === b.y)) { + return next; + } + } + return prev; +} + +export function syncSizesRecord( + prev: Record, + activeIds: string[], +): Record { + const active = new Set(activeIds); + const next: Record = {}; + for (const [id, size] of Object.entries(prev)) { + if (active.has(id)) { + next[id] = size; + } + } + + const prevIds = Object.keys(prev); + const nextIds = Object.keys(next); + if (prevIds.length !== nextIds.length) { + return next; + } + for (const id of nextIds) { + const a = prev[id]; + const b = next[id]; + if (!(a && b && a.width === b.width && a.height === b.height)) { + return next; + } + } + return prev; +} + diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts new file mode 100644 index 0000000000..fa25161f4e --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/stores/helpers/removals.ts @@ -0,0 +1,78 @@ +import type { Edge } from "@xyflow/react"; +import type { NodeConfig } from "../../types"; +import { isCategoryConfig, isSubcategoryConfig } from "../../utils"; +import { applyRemovalToConfig, applyRemovalToConfigs } from "../recipe-studio-helpers"; + +export function applyNodeRemovals( + input: { edges: Edge[]; configs: Record }, + removedIds: string[], +): { edges: Edge[]; configs: Record } { + if (removedIds.length === 0) { + return input; + } + + const edges = input.edges.filter( + (edge) => !(removedIds.includes(edge.source) || removedIds.includes(edge.target)), + ); + let configs: Record = { ...input.configs }; + const removedNames: string[] = []; + + for (const id of removedIds) { + const removed = configs[id]; + delete configs[id]; + if (removed?.name) { + removedNames.push(removed.name); + } + + if (isCategoryConfig(removed)) { + const removedName = removed.name; + for (const config of Object.values(configs)) { + if (!isSubcategoryConfig(config)) { + continue; + } + if (config.subcategory_parent !== removedName) { + continue; + } + configs[config.id] = { + ...config, + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_parent: "", + // biome-ignore lint/style/useNamingConvention: api schema + subcategory_mapping: {}, + }; + } + } + } + + for (const name of removedNames) { + configs = applyRemovalToConfigs(configs, name); + } + + return { edges, configs }; +} + +export function applyEdgeRemovals( + configs: Record, + removedEdges: Edge[], +): Record { + if (removedEdges.length === 0) { + return configs; + } + + let next = configs; + for (const edge of removedEdges) { + const source = next[edge.source]; + const target = next[edge.target]; + if (!(source && target)) { + continue; + } + const updated = applyRemovalToConfig(target, source.name); + if (updated !== target) { + if (next === configs) { + next = { ...configs }; + } + next[target.id] = updated; + } + } + return next; +} 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 972045a2cd..2b8ad0dac1 100644 --- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts +++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts @@ -22,13 +22,12 @@ import { type BlockKind, type BlockType, } from "../blocks/registry"; -import { isCategoryConfig, isSubcategoryConfig } from "../utils"; import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph"; import type { RecipeSnapshot } from "../utils/import"; import { getLayoutedElements } from "../utils/layout"; +import { syncPositionsRecord, syncSizesRecord } from "./helpers/aux-sync"; +import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals"; import { - applyRemovalToConfig, - applyRemovalToConfigs, applyRenameToConfigs, applyLayoutDirectionToNodes, buildNodeUpdate, @@ -86,6 +85,37 @@ type RecipeStudioState = { isValidConnection: IsValidConnection; }; +const INITIAL_STATE = { + nodes: [], + edges: [], + auxNodePositions: {}, + auxNodeSizes: {}, + configs: {}, + processors: [], + flowMoving: false, + sheetView: "root", + activeConfigId: null, + dialogOpen: false, + layoutDirection: "LR", + nextId: 3, + nextY: 280, +} satisfies Pick< + RecipeStudioState, + | "nodes" + | "edges" + | "auxNodePositions" + | "auxNodeSizes" + | "configs" + | "processors" + | "flowMoving" + | "sheetView" + | "activeConfigId" + | "dialogOpen" + | "layoutDirection" + | "nextId" + | "nextY" +>; + function buildAddedNodeState( state: RecipeStudioState, kind: BlockKind, @@ -102,39 +132,12 @@ function buildAddedNodeState( } export const useRecipeStudioStore = create((set, get) => ({ - nodes: [], - edges: [], - auxNodePositions: {}, - auxNodeSizes: {}, - configs: {}, - processors: [], - flowMoving: false, - sheetView: "root", - activeConfigId: null, - dialogOpen: false, - layoutDirection: "LR", - nextId: 3, - nextY: 280, + ...INITIAL_STATE, setFlowMoving: (moving) => set({ flowMoving: moving }), setSheetView: (view) => set({ sheetView: view }), setProcessors: (processors) => set({ processors }), setDialogOpen: (open) => set({ dialogOpen: open }), - resetRecipe: () => - set({ - nodes: [], - edges: [], - auxNodePositions: {}, - auxNodeSizes: {}, - configs: {}, - processors: [], - flowMoving: false, - sheetView: "root", - activeConfigId: null, - dialogOpen: false, - layoutDirection: "LR", - nextId: 3, - nextY: 280, - }), + resetRecipe: () => set(INITIAL_STATE), selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }), openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }), setLayoutDirection: (direction) => @@ -239,54 +242,13 @@ export const useRecipeStudioStore = create((set, get) => ({ }), syncAuxNodePositions: (activeIds, defaults) => set((state) => { - const nextPositions: Record = {}; - for (const id of activeIds) { - const existing = state.auxNodePositions[id]; - if (existing) { - nextPositions[id] = existing; - continue; - } - const fallback = defaults[id]; - if (fallback) { - nextPositions[id] = fallback; - } - } - const prevIds = Object.keys(state.auxNodePositions); - const nextIds = Object.keys(nextPositions); - if (prevIds.length !== nextIds.length) { - return { auxNodePositions: nextPositions }; - } - for (const id of nextIds) { - const prev = state.auxNodePositions[id]; - const next = nextPositions[id]; - if (!(prev && prev.x === next.x && prev.y === next.y)) { - return { auxNodePositions: nextPositions }; - } - } - return state; + const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults); + return next === state.auxNodePositions ? state : { auxNodePositions: next }; }), syncAuxNodeSizes: (activeIds) => set((state) => { - const activeSet = new Set(activeIds); - const nextSizes: Record = {}; - for (const [id, size] of Object.entries(state.auxNodeSizes)) { - if (activeSet.has(id)) { - nextSizes[id] = size; - } - } - const prevIds = Object.keys(state.auxNodeSizes); - const nextIds = Object.keys(nextSizes); - if (prevIds.length !== nextIds.length) { - return { auxNodeSizes: nextSizes }; - } - for (const id of nextIds) { - const prev = state.auxNodeSizes[id]; - const next = nextSizes[id]; - if (!(prev && prev.width === next.width && prev.height === next.height)) { - return { auxNodeSizes: nextSizes }; - } - } - return state; + const next = syncSizesRecord(state.auxNodeSizes, activeIds); + return next === state.auxNodeSizes ? state : { auxNodeSizes: next }; }), updateConfig: (id, patch) => { const applyUpdate = (state: RecipeStudioState) => { @@ -327,56 +289,17 @@ export const useRecipeStudioStore = create((set, get) => ({ set(applyUpdate); }, onNodesChange: (changes) => { - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update const applyNodesChange = (state: RecipeStudioState) => { const removedIds = changes .filter((change) => change.type === "remove") .map((change) => change.id); - let edges = state.edges; - let configs = state.configs; - if (removedIds.length > 0) { - const removedNames: string[] = []; - edges = edges.filter( - (edge) => - !( - removedIds.includes(edge.source) || - removedIds.includes(edge.target) - ), - ); - configs = { ...configs }; - for (const id of removedIds) { - const removed = configs[id]; - delete configs[id]; - if (removed?.name) { - removedNames.push(removed.name); - } - if (isCategoryConfig(removed)) { - const removedName = removed.name; - for (const config of Object.values(configs)) { - if (!isSubcategoryConfig(config)) { - continue; - } - if (config.subcategory_parent !== removedName) { - continue; - } - configs[config.id] = { - ...config, - // biome-ignore lint/style/useNamingConvention: api schema - subcategory_parent: "", - // biome-ignore lint/style/useNamingConvention: api schema - subcategory_mapping: {}, - }; - } - } - } - for (const name of removedNames) { - configs = applyRemovalToConfigs(configs, name); - } - } - + const removed = applyNodeRemovals( + { edges: state.edges, configs: state.configs }, + removedIds, + ); const nodes = applyNodeChanges(changes, state.nodes); - return { nodes, edges, configs }; + return { nodes, edges: removed.edges, configs: removed.configs }; }; set(applyNodesChange); }, @@ -387,23 +310,7 @@ export const useRecipeStudioStore = create((set, get) => ({ .map((change) => state.edges.find((edge) => edge.id === change.id)) .filter((edge): edge is Edge => Boolean(edge)); - let configs = state.configs; - if (removedEdges.length > 0) { - for (const edge of removedEdges) { - const source = configs[edge.source]; - const target = configs[edge.target]; - if (!(source && target)) { - continue; - } - const updated = applyRemovalToConfig(target, source.name); - if (updated !== target) { - if (configs === state.configs) { - configs = { ...configs }; - } - configs[target.id] = updated; - } - } - } + const configs = applyEdgeRemovals(state.configs, removedEdges); const edges = applyEdgeChanges(changes, state.edges); return configs === state.configs ? { edges } : { edges, configs }; 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 030e39fa49..e47e2b04eb 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 @@ -3,6 +3,8 @@ import type { RecipeGraphAuxNodeData } from "../../components/recipe-graph-aux-n import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants"; import type { RecipeNode, LayoutDirection, NodeConfig } from "../../types"; import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../handles"; +import { readNodeHeight, readNodeWidth } from "../rf-node-dimensions"; +import { isSemanticRelation } from "./relations"; type DisplayGraphInput = { nodes: RecipeNode[]; @@ -33,10 +35,7 @@ function normalizeEdge(edge: Edge, configs: Record): Edge { const source = configs[edge.source]; const target = configs[edge.target]; - const semantic = - Boolean(source && target) && - ((source.kind === "model_provider" && target?.kind === "model_config") || - (source.kind === "model_config" && target?.kind === "llm")); + const semantic = Boolean(source && target) && isSemanticRelation(source, target); const handles = semantic ? { sourceHandle: HANDLE_IDS.semanticOut, targetHandle: HANDLE_IDS.semanticIn } : { sourceHandle: HANDLE_IDS.dataOut, targetHandle: HANDLE_IDS.dataIn }; @@ -55,50 +54,6 @@ type AuxNodeItem = { data: RecipeGraphAuxNodeData; }; -function getNodeWidth(node: Node): number { - if (typeof node.width === "number" && Number.isFinite(node.width)) { - return node.width; - } - if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) { - return node.style.width; - } - if (typeof node.style?.width === "string") { - const parsed = Number.parseFloat(node.style.width); - if (Number.isFinite(parsed)) { - return parsed; - } - } - if ( - typeof node.measured?.width === "number" && - Number.isFinite(node.measured.width) - ) { - return node.measured.width; - } - return DEFAULT_NODE_WIDTH; -} - -function getNodeHeight(node: Node): number { - if (typeof node.height === "number" && Number.isFinite(node.height)) { - return node.height; - } - if (typeof node.style?.height === "number" && Number.isFinite(node.style.height)) { - return node.style.height; - } - if (typeof node.style?.height === "string") { - const parsed = Number.parseFloat(node.style.height); - if (Number.isFinite(parsed)) { - return parsed; - } - } - if ( - typeof node.measured?.height === "number" && - Number.isFinite(node.measured.height) - ) { - return node.measured.height; - } - return DEFAULT_NODE_HEIGHT; -} - export function deriveDisplayGraph({ nodes, edges, @@ -181,8 +136,8 @@ export function deriveDisplayGraph({ continue; } - const parentWidth = getNodeWidth(node); - const parentHeight = getNodeHeight(node); + const parentWidth = readNodeWidth(node) ?? DEFAULT_NODE_WIDTH; + const parentHeight = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT; const itemsWithLayout = items.map((item) => { const auxId = `aux-${node.id}-${item.key}`; const savedSize = auxNodeSizes[auxId]; diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts index dee68af443..54b6f0014f 100644 --- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts +++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts @@ -1,6 +1,7 @@ import { type Connection, type Edge, addEdge } from "@xyflow/react"; import type { NodeConfig, SamplerConfig } from "../../types"; import { HANDLE_IDS } from "../handles"; +import { isSemanticRelation } from "./relations"; import { isCategoryConfig, isExpressionConfig, @@ -46,13 +47,6 @@ function syncSubcategoryMapping( }; } -function isSemanticRelation(source: NodeConfig, target: NodeConfig): boolean { - if (source.kind === "model_provider" && target.kind === "model_config") { - return true; - } - return source.kind === "model_config" && target.kind === "llm"; -} - function isModelInfraNode(config: NodeConfig): boolean { return config.kind === "model_provider" || config.kind === "model_config"; } diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts new file mode 100644 index 0000000000..9efdcb55d0 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/graph/relations.ts @@ -0,0 +1,12 @@ +import type { NodeConfig } from "../../types"; + +export function isSemanticRelation( + source: NodeConfig, + target: NodeConfig, +): boolean { + if (source.kind === "model_provider" && target.kind === "model_config") { + return true; + } + return source.kind === "model_config" && target.kind === "llm"; +} + diff --git a/studio/frontend/src/features/recipe-studio/utils/parse.ts b/studio/frontend/src/features/recipe-studio/utils/parse.ts new file mode 100644 index 0000000000..ea422533db --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/parse.ts @@ -0,0 +1,60 @@ +export function parseNumber(value?: string): number | null { + if (!value) { + return null; + } + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +export function parseIntNumber(value?: string): number | null { + const num = parseNumber(value); + if (num === null || !Number.isInteger(num)) { + return null; + } + return num; +} + +export function parseAgeRange(value?: string): [number, number] | null { + if (!value) { + return null; + } + const parts = value.split(/[^0-9.]+/).filter(Boolean); + if (parts.length !== 2) { + return null; + } + const min = Number(parts[0]); + const max = Number(parts[1]); + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return null; + } + return [min, max]; +} + +export function parseJsonObject( + value: string | undefined, + label: string, + errors: string[], +): Record | undefined { + if (!value || !value.trim()) { + return undefined; + } + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + errors.push(`${label}: invalid JSON.`); + return undefined; + } + errors.push(`${label}: must be a JSON object.`); + return undefined; +} + +export function isValidSex(value?: string): value is "Male" | "Female" { + if (!value) { + return false; + } + return value === "Male" || value === "Female"; +} + diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts index 1ff1f59ca0..9185d8998e 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts @@ -7,6 +7,8 @@ import type { NodeConfig, } from "../../types"; import { getConfigErrors } from "../index"; +import { isSemanticRelation } from "../graph/relations"; +import { readNodeWidth } from "../rf-node-dimensions"; import { buildExpressionColumn, buildLlmMcpProvider, @@ -22,7 +24,6 @@ import { } from "./builders"; import type { RecipePayloadResult } from "./types"; import { - isSemanticRelation, validateModelAliasLinks, validateModelConfigProviders, validateSubcategoryConfigs, @@ -30,20 +31,24 @@ import { validateUsedProviders, } from "./validate"; -function getNodeWidth(node: RecipeNode): number | null { - if (typeof node.width === "number" && Number.isFinite(node.width)) { - return node.width; +function pushUniqueJson( + label: string, + key: string, + item: Record, + seen: Map, + out: Record[], + errors: string[], +): void { + const serialized = JSON.stringify(item); + const existing = seen.get(key); + if (existing && existing !== serialized) { + errors.push(`${label} ${key}: conflicting definitions.`); + return; } - if (typeof node.style?.width === "number" && Number.isFinite(node.style.width)) { - return node.style.width; + if (!existing) { + seen.set(key, serialized); + out.push(item); } - if (typeof node.style?.width === "string") { - const parsed = Number.parseFloat(node.style.width); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; } // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build @@ -97,34 +102,28 @@ export function buildRecipePayload( if (!builtProvider) { continue; } - const key = String(builtProvider.name); - const serialized = JSON.stringify(builtProvider); - const existing = mcpProviderJsonByName.get(key); - if (existing && existing !== serialized) { - errors.push(`MCP provider ${key}: conflicting definitions.`); - continue; - } - if (!existing) { - mcpProviderJsonByName.set(key, serialized); - mcpProviders.push(builtProvider); - } + pushUniqueJson( + "MCP provider", + String(builtProvider.name), + builtProvider, + mcpProviderJsonByName, + mcpProviders, + errors, + ); } for (const toolConfig of config.tool_configs ?? []) { const builtToolConfig = buildLlmToolConfig(toolConfig, errors); if (!builtToolConfig) { continue; } - const key = String(builtToolConfig.tool_alias); - const serialized = JSON.stringify(builtToolConfig); - const existing = toolConfigJsonByAlias.get(key); - if (existing && existing !== serialized) { - errors.push(`Tool config ${key}: conflicting definitions.`); - continue; - } - if (!existing) { - toolConfigJsonByAlias.set(key, serialized); - toolConfigs.push(builtToolConfig); - } + pushUniqueJson( + "Tool config", + String(builtToolConfig.tool_alias), + builtToolConfig, + toolConfigJsonByAlias, + toolConfigs, + errors, + ); } if (config.model_alias) { modelAliases.add(config.model_alias); @@ -179,7 +178,7 @@ export function buildRecipePayload( if (config.kind === "seed") { return []; } - const width = getNodeWidth(node); + const width = readNodeWidth(node); return [ { id: config.name, diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts b/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts new file mode 100644 index 0000000000..b60bf31e72 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/payload/empty.ts @@ -0,0 +1,29 @@ +import type { RecipePayload } from "./types"; + +export function createEmptyRecipePayload(): RecipePayload { + return { + recipe: { + // biome-ignore lint/style/useNamingConvention: api schema + model_providers: [], + // biome-ignore lint/style/useNamingConvention: api schema + mcp_providers: [], + // biome-ignore lint/style/useNamingConvention: api schema + model_configs: [], + // biome-ignore lint/style/useNamingConvention: api schema + tool_configs: [], + columns: [], + processors: [], + }, + run: { + rows: 5, + preview: true, + // biome-ignore lint/style/useNamingConvention: api schema + output_formats: ["jsonl"], + }, + ui: { + nodes: [], + edges: [], + }, + }; +} + diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/index.ts b/studio/frontend/src/features/recipe-studio/utils/payload/index.ts index 4cfbb4aa20..11877e0820 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/index.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/index.ts @@ -1,2 +1,3 @@ export { buildRecipePayload } from "./build-payload"; +export { createEmptyRecipePayload } from "./empty"; export type { RecipePayload, RecipePayloadResult } from "./types"; diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/parse.ts b/studio/frontend/src/features/recipe-studio/utils/payload/parse.ts index f6ebdceaa2..782683d4fb 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/parse.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/parse.ts @@ -1,51 +1,7 @@ -export function parseNumber(value?: string): number | null { - if (!value) { - return null; - } - const num = Number(value); - return Number.isFinite(num) ? num : null; -} +export { + isValidSex, + parseAgeRange, + parseJsonObject, + parseNumber, +} from "../parse"; -export function parseAgeRange(value?: string): [number, number] | null { - if (!value) { - return null; - } - const parts = value.split(/[^0-9.]+/).filter(Boolean); - if (parts.length !== 2) { - return null; - } - const min = Number(parts[0]); - const max = Number(parts[1]); - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return null; - } - return [min, max]; -} - -export function parseJsonObject( - value: string | undefined, - label: string, - errors: string[], -): Record | undefined { - if (!value || !value.trim()) { - return undefined; - } - try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - } catch { - errors.push(`${label}: invalid JSON.`); - return undefined; - } - errors.push(`${label}: must be a JSON object.`); - return undefined; -} - -export function isValidSex(value?: string): value is "Male" | "Female" { - if (!value) { - return false; - } - return value === "Male" || value === "Female"; -} diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts index f7be3caf22..f4348886eb 100644 --- a/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts +++ b/studio/frontend/src/features/recipe-studio/utils/payload/validate.ts @@ -1,15 +1,5 @@ import type { ModelConfig, ModelProviderConfig, NodeConfig } from "../../types"; -export function isSemanticRelation( - source: NodeConfig, - target: NodeConfig, -): boolean { - if (source.kind === "model_provider" && target.kind === "model_config") { - return true; - } - return source.kind === "model_config" && target.kind === "llm"; -} - export function validateSubcategoryConfigs( configs: Record, nameToConfig: Map, diff --git a/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts new file mode 100644 index 0000000000..bf6ebc397f --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts @@ -0,0 +1,65 @@ +import type { + Edge, + EdgeChange, + Node, + NodeChange, + XYPosition, +} from "@xyflow/react"; +import type { RecipeGraphAuxNodeData } from "../components/recipe-graph-aux-node"; +import type { RecipeNodeData } from "../types"; + +type AnyNode = Node; + +export function applyAuxNodeChanges( + changes: NodeChange[], + actions: { + setAuxNodePosition: (id: string, position: XYPosition) => void; + setAuxNodeSize: ( + id: string, + size: { width: number; height: number }, + ) => void; + }, +): void { + for (const change of changes) { + if (!("id" in change) || !change.id.startsWith("aux-")) { + continue; + } + if (change.type === "position") { + const nextPosition = change.position ?? change.positionAbsolute; + if (nextPosition) { + actions.setAuxNodePosition(change.id, nextPosition); + } + continue; + } + if ( + change.type === "dimensions" && + change.dimensions && + change.dimensions.width > 0 && + change.dimensions.height > 0 + ) { + actions.setAuxNodeSize(change.id, { + width: change.dimensions.width, + height: change.dimensions.height, + }); + } + } +} + +export function filterNodeChangesByIds( + changes: NodeChange[], + ids: Set, +): NodeChange[] { + return changes.filter( + (change): change is NodeChange => "id" in change && ids.has(change.id), + ); +} + +export function filterEdgeChangesByIds( + changes: EdgeChange[], + ids: Set, +): EdgeChange[] { + return changes.filter( + (change): change is EdgeChange => "id" in change && ids.has(change.id), + ); +} + diff --git a/studio/frontend/src/features/recipe-studio/utils/rf-node-dimensions.ts b/studio/frontend/src/features/recipe-studio/utils/rf-node-dimensions.ts new file mode 100644 index 0000000000..04881ab881 --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/rf-node-dimensions.ts @@ -0,0 +1,31 @@ +import type { Node } from "@xyflow/react"; + +function parseDim(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function readNodeWidth(node: Node): number | null { + return ( + parseDim(node.width) ?? + parseDim(node.style?.width) ?? + parseDim(node.measured?.width) ?? + null + ); +} + +export function readNodeHeight(node: Node): number | null { + return ( + parseDim(node.height) ?? + parseDim(node.style?.height) ?? + parseDim(node.measured?.height) ?? + null + ); +} + diff --git a/studio/frontend/src/features/recipe-studio/utils/validation.ts b/studio/frontend/src/features/recipe-studio/utils/validation.ts index fda4643af0..ba319255af 100644 --- a/studio/frontend/src/features/recipe-studio/utils/validation.ts +++ b/studio/frontend/src/features/recipe-studio/utils/validation.ts @@ -1,36 +1,5 @@ import type { NodeConfig } from "../types"; - -function parseNumber(value?: string): number | null { - if (!value) { - return null; - } - const num = Number(value); - return Number.isFinite(num) ? num : null; -} - -function parseIntNumber(value?: string): number | null { - const num = parseNumber(value); - if (num === null || !Number.isInteger(num)) { - return null; - } - return num; -} - -function parseAgeRange(value?: string): [number, number] | null { - if (!value) { - return null; - } - const parts = value.split(/[^0-9.]+/).filter(Boolean); - if (parts.length !== 2) { - return null; - } - const min = Number(parts[0]); - const max = Number(parts[1]); - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return null; - } - return [min, max]; -} +import { isValidSex, parseAgeRange, parseIntNumber, parseNumber } from "./parse"; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation rules export function getConfigErrors(config: NodeConfig | null): string[] { @@ -142,7 +111,7 @@ export function getConfigErrors(config: NodeConfig | null): string[] { if (config.sampler_type === "person") { if (config.person_sex?.trim()) { const normalized = config.person_sex.trim(); - if (!(normalized === "Male" || normalized === "Female")) { + if (!isValidSex(normalized)) { errors.push("Person sex must be Male or Female."); } } diff --git a/studio/frontend/src/utils/index.ts b/studio/frontend/src/utils/index.ts index f072794c73..f2bd368099 100644 --- a/studio/frontend/src/utils/index.ts +++ b/studio/frontend/src/utils/index.ts @@ -1,2 +1,2 @@ // Utility functions -export {}; +export { normalizeNonEmptyName } from "./strings"; diff --git a/studio/frontend/src/utils/strings.ts b/studio/frontend/src/utils/strings.ts new file mode 100644 index 0000000000..a9f1bc5797 --- /dev/null +++ b/studio/frontend/src/utils/strings.ts @@ -0,0 +1,8 @@ +export function normalizeNonEmptyName( + value: string, + fallback = "Unnamed", +): string { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} +