feat: refactor block definitions and utilities into modular components for enhanced maintainability

This commit is contained in:
Shine1i 2026-02-14 19:13:19 +01:00
commit 964f7d1548
29 changed files with 928 additions and 874 deletions

View file

@ -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<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
@ -57,7 +26,7 @@ export async function saveRecipe(input: SaveRecipeInput): Promise<RecipeRecord>
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<void> {
export async function createRecipeDraft(): Promise<RecipeRecord> {
return saveRecipe({
name: "Unnamed",
payload: createEmptyPayload(),
payload: createEmptyRecipePayload(),
});
}

View file

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

View file

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

View file

@ -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<NodeConfig>) => 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" ? (
<SeedDialog
config={config}
onUpdate={(patch) => 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" ? (
<CategoryDialog
key={config.id}
config={config}
onUpdate={(patch) => 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" ? (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={(patch) => 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" ? (
<UniformDialog
config={config}
onUpdate={(patch) => 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" ? (
<GaussianDialog
config={config}
onUpdate={(patch) => 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" ? (
<BernoulliDialog
config={config}
onUpdate={(patch) => 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" ? (
<DatetimeDialog
config={config}
onUpdate={(patch) => 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" ? (
<TimedeltaDialog
config={config}
datetimeOptions={datetimeOptions}
onUpdate={(patch) => 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" ? (
<UuidDialog
config={config}
onUpdate={(patch) => 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") ? (
<PersonDialog
config={config}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => 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" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={(patch) => 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" ? (
<ModelProviderDialog
config={config}
onUpdate={(patch) => 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" ? (
<ModelConfigDialog
config={config}
providerOptions={modelProviderOptions}
onUpdate={(patch) => 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" ? (
<ExpressionDialog
config={config}
onUpdate={(patch) => 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<NodeConfig>) => void,
): ReactElement | null {
const definition = getBlockDefinitionForConfig(config);
if (!definition || !config) {
return null;
}
return definition.renderDialog({
config,
categoryOptions,
modelConfigAliases,
modelProviderOptions,
datetimeOptions,
onUpdate,
});
}

View file

@ -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<NodeConfig>) => void,
): ReactElement | null {
const definition = getBlockDefinitionForConfig(config);
if (!definition || !config) {
return null;
}
const update = (patch: Partial<NodeConfig>) => onUpdate(config.id, patch);
switch (definition.dialogKey) {
case "seed":
return config.kind === "seed" ? (
<SeedDialog config={config} onUpdate={update} />
) : null;
case "category":
return config.kind === "sampler" && config.sampler_type === "category" ? (
<CategoryDialog key={config.id} config={config} onUpdate={update} />
) : null;
case "subcategory":
return config.kind === "sampler" && config.sampler_type === "subcategory" ? (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={update}
/>
) : null;
case "uniform":
return config.kind === "sampler" && config.sampler_type === "uniform" ? (
<UniformDialog config={config} onUpdate={update} />
) : null;
case "gaussian":
return config.kind === "sampler" && config.sampler_type === "gaussian" ? (
<GaussianDialog config={config} onUpdate={update} />
) : null;
case "bernoulli":
return config.kind === "sampler" && config.sampler_type === "bernoulli" ? (
<BernoulliDialog config={config} onUpdate={update} />
) : null;
case "datetime":
return config.kind === "sampler" && config.sampler_type === "datetime" ? (
<DatetimeDialog config={config} onUpdate={update} />
) : null;
case "timedelta":
return config.kind === "sampler" && config.sampler_type === "timedelta" ? (
<TimedeltaDialog
config={config}
datetimeOptions={datetimeOptions}
onUpdate={update}
/>
) : null;
case "uuid":
return config.kind === "sampler" && config.sampler_type === "uuid" ? (
<UuidDialog config={config} onUpdate={update} />
) : null;
case "person":
return config.kind === "sampler" &&
(config.sampler_type === "person" ||
config.sampler_type === "person_from_faker") ? (
<PersonDialog config={config} onUpdate={update} />
) : null;
case "llm":
return config.kind === "llm" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
onUpdate={update}
/>
) : null;
case "model_provider":
return config.kind === "model_provider" ? (
<ModelProviderDialog config={config} onUpdate={update} />
) : null;
case "model_config":
return config.kind === "model_config" ? (
<ModelConfigDialog
config={config}
providerOptions={modelProviderOptions}
onUpdate={update}
/>
) : null;
case "expression":
return config.kind === "expression" ? (
<ExpressionDialog config={config} onUpdate={update} />
) : null;
}
}

View file

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

View file

@ -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<HTMLDivElement>(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 <p className="text-xs text-muted-foreground">No values</p>;

View file

@ -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<HTMLDivElement>(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 (
<div className="space-y-3">
@ -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,
});
}
}}

View file

@ -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<HTMLDivElement>(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 = <K extends keyof ModelConfig>(
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);
}
}}
/>

View file

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

View file

@ -5,3 +5,4 @@ export type {
RecipeStudioPageProps,
} from "./recipe-studio-page";
export type { RecipePayload } from "./utils/payload/types";
export { createEmptyRecipePayload } from "./utils/payload/empty";

View file

@ -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<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
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<RecipeBuilderNode> =>
"id" in change && baseNodeIds.has(change.id),
applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
const next = filterNodeChangesByIds(
changes as NodeChange<RecipeBuilderNode>[],
baseNodeIds,
);
if (next.length > 0) {
if (next.length) {
onNodesChange(next);
}
},
@ -231,11 +216,8 @@ export function RecipeStudioPage({
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
const next = changes.filter(
(change): change is EdgeChange<Edge> =>
"id" in change && baseEdgeIds.has(change.id),
);
if (next.length > 0) {
const next = filterEdgeChangesByIds(changes, baseEdgeIds);
if (next.length) {
onEdgesChange(next);
}
},

View file

@ -0,0 +1,62 @@
import type { XYPosition } from "@xyflow/react";
export function syncPositionsRecord(
prev: Record<string, XYPosition>,
activeIds: string[],
defaults: Record<string, XYPosition>,
): Record<string, XYPosition> {
const next: Record<string, XYPosition> = {};
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<string, { width: number; height: number }>,
activeIds: string[],
): Record<string, { width: number; height: number }> {
const active = new Set(activeIds);
const next: Record<string, { width: number; height: number }> = {};
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;
}

View file

@ -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<string, NodeConfig> },
removedIds: string[],
): { edges: Edge[]; configs: Record<string, NodeConfig> } {
if (removedIds.length === 0) {
return input;
}
const edges = input.edges.filter(
(edge) => !(removedIds.includes(edge.source) || removedIds.includes(edge.target)),
);
let configs: Record<string, NodeConfig> = { ...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<string, NodeConfig>,
removedEdges: Edge[],
): Record<string, NodeConfig> {
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;
}

View file

@ -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<RecipeStudioState>((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<RecipeStudioState>((set, get) => ({
}),
syncAuxNodePositions: (activeIds, defaults) =>
set((state) => {
const nextPositions: Record<string, XYPosition> = {};
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<string, { width: number; height: number }> = {};
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<RecipeStudioState>((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<RecipeNode>(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<RecipeStudioState>((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 };

View file

@ -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<string, NodeConfig>): 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];

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>,
seen: Map<string, string>,
out: Record<string, unknown>[],
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,

View file

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

View file

@ -1,2 +1,3 @@
export { buildRecipePayload } from "./build-payload";
export { createEmptyRecipePayload } from "./empty";
export type { RecipePayload, RecipePayloadResult } from "./types";

View file

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

View file

@ -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<string, NodeConfig>,
nameToConfig: Map<string, NodeConfig>,

View file

@ -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<RecipeNodeData | RecipeGraphAuxNodeData>;
export function applyAuxNodeChanges(
changes: NodeChange<AnyNode>[],
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<T extends Node>(
changes: NodeChange<T>[],
ids: Set<string>,
): NodeChange<T>[] {
return changes.filter(
(change): change is NodeChange<T> => "id" in change && ids.has(change.id),
);
}
export function filterEdgeChangesByIds(
changes: EdgeChange<Edge>[],
ids: Set<string>,
): EdgeChange<Edge>[] {
return changes.filter(
(change): change is EdgeChange<Edge> => "id" in change && ids.has(change.id),
);
}

View file

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

View file

@ -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.");
}
}

View file

@ -1,2 +1,2 @@
// Utility functions
export {};
export { normalizeNonEmptyName } from "./strings";

View file

@ -0,0 +1,8 @@
export function normalizeNonEmptyName(
value: string,
fallback = "Unnamed",
): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : fallback;
}