feat: refactor block definitions and utilities into modular components for enhanced maintainability
This commit is contained in:
parent
f2a00d6e44
commit
964f7d1548
29 changed files with 928 additions and 874 deletions
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
294
studio/frontend/src/features/recipe-studio/blocks/definitions.ts
Normal file
294
studio/frontend/src/features/recipe-studio/blocks/definitions.ts
Normal 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");
|
||||
}
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ export type {
|
|||
RecipeStudioPageProps,
|
||||
} from "./recipe-studio-page";
|
||||
export type { RecipePayload } from "./utils/payload/types";
|
||||
export { createEmptyRecipePayload } from "./utils/payload/empty";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
60
studio/frontend/src/features/recipe-studio/utils/parse.ts
Normal file
60
studio/frontend/src/features/recipe-studio/utils/parse.ts
Normal 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";
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { buildRecipePayload } from "./build-payload";
|
||||
export { createEmptyRecipePayload } from "./empty";
|
||||
export type { RecipePayload, RecipePayloadResult } from "./types";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
// Utility functions
|
||||
export {};
|
||||
export { normalizeNonEmptyName } from "./strings";
|
||||
|
|
|
|||
8
studio/frontend/src/utils/strings.ts
Normal file
8
studio/frontend/src/utils/strings.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export function normalizeNonEmptyName(
|
||||
value: string,
|
||||
fallback = "Unnamed",
|
||||
): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue