refactor: streamline samplers and block handling, update dialogs and validation

This commit is contained in:
Shine1i 2026-02-22 02:16:09 +01:00
commit 6f4c0c90bb
17 changed files with 371 additions and 257 deletions

View file

@ -72,7 +72,7 @@ export type BlockDefinition = {
export const BLOCK_GROUPS: BlockGroup[] = [
{
kind: "sampler",
title: "Sampler",
title: "Samplers",
description: "Numeric + categorical blocks.",
icon: DiceFaces03Icon,
},
@ -84,8 +84,8 @@ export const BLOCK_GROUPS: BlockGroup[] = [
},
{
kind: "llm",
title: "LLM",
description: "Text + structured blocks.",
title: "LLM + Models",
description: "Generation, providers, and model aliases.",
icon: PencilEdit02Icon,
},
{
@ -182,7 +182,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "sampler",
type: "person",
title: "Person",
description: "Synthetic person sampler.",
description: "Faker person sampler.",
icon: UserAccountIcon,
dialogKey: "person",
createConfig: (id, existing) => makeSamplerConfig(id, "person", existing),
@ -227,7 +227,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "llm",
type: "model_provider",
title: "Model Provider",
description: "Configure API endpoint + key.",
description: "Endpoint, auth, and provider settings.",
icon: Shield02Icon,
dialogKey: "model_provider",
createConfig: (id, existing) => makeModelProviderConfig(id, existing),
@ -236,7 +236,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
kind: "llm",
type: "model_config",
title: "Model Config",
description: "Alias + model + inference params.",
description: "Alias, model, provider, and inference params.",
icon: Plant01Icon,
dialogKey: "model_config",
createConfig: (id, existing) => makeModelConfig(id, existing),
@ -291,4 +291,3 @@ export function getBlockDefinitionForConfig(
}
return getBlockDefinition("expression", "expression");
}

View file

@ -87,6 +87,7 @@ export function renderBlockDialog(
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
onUpdate={update}
/>
) : null;

View file

@ -36,6 +36,8 @@ type BlockSheetProps = {
container: HTMLDivElement | null;
sheetView: SheetView;
onViewChange: (sheetView: SheetView) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAddSampler: (type: SamplerType) => void;
onAddSeed: () => void;
onAddLlm: (type: LlmType) => void;
@ -128,6 +130,8 @@ export function BlockSheet({
container,
sheetView,
onViewChange,
open,
onOpenChange,
onAddSampler,
onAddSeed,
onAddLlm,
@ -140,16 +144,26 @@ export function BlockSheet({
onImport,
}: BlockSheetProps): ReactElement {
const sheetTitle = getSheetTitle(sheetView);
const [open, setOpen] = useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []);
const seedBlocks = useMemo(() => getBlocksForKind("seed"), []);
const isControlled = typeof open === "boolean";
const sheetOpen = isControlled ? (open as boolean) : uncontrolledOpen;
const setSheetOpen = (nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
return (
<div className="flex flex-col items-end gap-2">
<Sheet
open={open}
onOpenChange={(open) => {
setOpen(open);
if (open) {
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen);
if (nextOpen) {
onViewChange("root");
}
}}
@ -201,17 +215,17 @@ export function BlockSheet({
isActive={index === 0}
onClick={() => {
if (item.kind === "processor") {
setOpen(false);
setSheetOpen(false);
onOpenProcessors();
return;
}
if (item.kind === "seed" && seedBlocks.length === 1) {
setOpen(false);
setSheetOpen(false);
onAddSeed();
return;
}
if (item.kind === "expression" && expressionBlocks.length === 1) {
setOpen(false);
setSheetOpen(false);
onAddExpression();
return;
}

View file

@ -253,13 +253,13 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
key={item.id}
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
>
<Handle
id={item.id}
type="target"
position={Position.Top}
className="pointer-events-auto !size-2 !border-border !bg-background"
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<Handle
id={item.id}
type="target"
position={Position.Top}
className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
@ -275,7 +275,7 @@ function LlmInputHandles({ items, isTopBottom }: LlmInputHandlesProps): ReactEle
id={item.id}
type="target"
position={Position.Left}
className="pointer-events-auto !size-2 !border-border !bg-background"
className="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="block truncate text-[10px] text-muted-foreground">
@ -383,7 +383,7 @@ function RecipeGraphNodeBase({
position={dataInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
/>
<LabeledHandle
id={HANDLE_IDS.dataOut}
@ -392,7 +392,7 @@ function RecipeGraphNodeBase({
position={dataOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
/>
</>
)}
@ -405,7 +405,7 @@ function RecipeGraphNodeBase({
position={semanticInPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
/>
)}
@ -417,7 +417,7 @@ function RecipeGraphNodeBase({
position={semanticOutPosition}
className="absolute inset-0 pointer-events-none"
labelClassName="sr-only"
handleClassName="pointer-events-auto !size-2 !border-border !bg-background"
handleClassName="pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20"
/>
)}
</BaseNode>

View file

@ -11,6 +11,7 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({
targetPosition,
style,
markerEnd,
selected,
}: EdgeProps): ReactElement {
const [path] = getSmoothStepPath({
sourceX,
@ -29,9 +30,12 @@ export const RecipeGraphSemanticEdge = memo(function RecipeGraphSemanticEdge({
path={path}
markerEnd={markerEnd}
style={{
strokeDasharray: "4 4",
strokeWidth: 1.5,
stroke: "var(--muted-foreground)",
strokeDasharray: selected ? "7 5" : "6 5",
strokeWidth: selected ? 2.3 : 1.8,
stroke: selected
? "hsl(var(--primary) / 0.9)"
: "hsl(var(--foreground) / 0.38)",
opacity: selected ? 1 : 0.92,
...style,
}}
/>

View file

@ -14,7 +14,7 @@ export function BaseHandle({
<Handle
{...props}
className={cn(
"dark:border-secondary dark:bg-secondary h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition",
"h-[12px] w-[12px] rounded-full border border-border/80 bg-muted shadow-[0_0_0_1px_hsl(var(--background))] transition-all hover:scale-110 hover:border-primary/70 hover:bg-primary/20",
className,
)}
>

View file

@ -17,6 +17,7 @@ export function DataEdge({
data = { path: "auto" },
id,
markerEnd,
selected,
sourcePosition,
sourceX,
sourceY,
@ -44,8 +45,17 @@ export function DataEdge({
targetPosition,
});
const edgeStyle = {
stroke: selected
? "hsl(var(--primary) / 0.92)"
: "hsl(var(--foreground) / 0.42)",
strokeWidth: selected ? 2.6 : 2.1,
opacity: selected ? 1 : 0.92,
...style,
};
return (
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={edgeStyle} />
);
}

View file

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import type { ReactElement } from "react";
import { getBlockDefinitionForConfig } from "../blocks/definitions";
import { renderBlockDialog } from "../blocks/registry";
import type { NodeConfig, SamplerConfig } from "../types";
import { DialogShell } from "./shared/dialog-shell";
@ -30,6 +31,8 @@ export function ConfigDialog({
onUpdate,
container,
}: ConfigDialogProps): ReactElement {
const blockDefinition = getBlockDefinitionForConfig(config);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@ -39,7 +42,14 @@ export function ConfigDialog({
overlayClassName="bg-transparent"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl shadow-border"
>
<DialogShell />
<DialogShell
title={blockDefinition ? `${blockDefinition.title} block` : undefined}
description={
blockDefinition
? blockDefinition.description
: "Adjust block params before running the flow."
}
/>
{!config && (
<div className="text-sm text-muted-foreground">
Select a node to edit.

View file

@ -41,6 +41,7 @@ const CODE_LANG_OPTIONS = [
type LlmGeneralTabProps = {
config: LlmConfig;
modelConfigAliases: string[];
modelProviderOptions: string[];
modelAliasAnchorRef: RefObject<HTMLDivElement | null>;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
@ -48,6 +49,7 @@ type LlmGeneralTabProps = {
export function LlmGeneralTab({
config,
modelConfigAliases,
modelProviderOptions,
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
@ -56,11 +58,22 @@ export function LlmGeneralTab({
const promptId = `${config.id}-prompt`;
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
const hasModelConfigs = modelConfigAliases.length > 0;
const hasModelProviders = modelProviderOptions.length > 0;
return (
<div className="space-y-4">
<AvailableVariables configId={config.id} />
<NameField value={config.name} onChange={(value) => onUpdate({ name: value })} />
{(!hasModelConfigs || !hasModelProviders) && (
<div className="rounded-2xl border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<p className="font-semibold text-foreground">Setup hint</p>
<p>
{!hasModelProviders && "Add a Model Provider block. "}
{!hasModelConfigs && "Add a Model Config block and pick its alias here."}
</p>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"

View file

@ -13,12 +13,14 @@ import { LlmScoresTab } from "./scores-tab";
type LlmDialogProps = {
config: LlmConfig;
modelConfigAliases: string[];
modelProviderOptions: string[];
onUpdate: (patch: Partial<LlmConfig>) => void;
};
export function LlmDialog({
config,
modelConfigAliases,
modelProviderOptions,
onUpdate,
}: LlmDialogProps): ReactElement {
const modelAliasAnchorRef = useRef<HTMLDivElement>(null);
@ -34,6 +36,7 @@ export function LlmDialog({
<LlmGeneralTab
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
modelAliasAnchorRef={modelAliasAnchorRef}
onUpdate={onUpdate}
/>

View file

@ -1,4 +1,9 @@
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { type ReactElement, useState } from "react";
import type { SamplerConfig } from "../../types";
@ -10,14 +15,38 @@ type CategoryDialogProps = {
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
function addChipWithWeight(
values: string[] | undefined,
weights: Array<number | null> | undefined,
value: string,
): { values: string[]; weights: Array<number | null> } {
return {
values: [...(values ?? []), value],
weights: [...(weights ?? []), null],
};
}
function removeChipWithWeight(
values: string[] | undefined,
weights: Array<number | null> | undefined,
index: number,
): { values: string[]; weights: Array<number | null> } {
const nextValues = [...(values ?? [])];
const nextWeights = [...(weights ?? [])];
nextValues.splice(index, 1);
nextWeights.splice(index, 1);
return { values: nextValues, weights: nextWeights };
}
export function CategoryDialog({
config,
onUpdate,
}: CategoryDialogProps): ReactElement {
const [conditionDraft, setConditionDraft] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const conditionInputId = `${config.id}-conditional-rule`;
const conditional = config.conditional_params ?? {};
const conditionalCount = Object.keys(conditional).length;
const handleAddCondition = () => {
const condition = conditionDraft.trim();
@ -62,164 +91,200 @@ export function CategoryDialog({
<ChipInput
values={config.values ?? []}
onAdd={(value) => {
const values = [...(config.values ?? []), value];
const weights = [...(config.weights ?? []), null];
const { values, weights } = addChipWithWeight(
config.values,
config.weights,
value,
);
onUpdate({ values, weights });
}}
onRemove={(index) => {
const values = [...(config.values ?? [])];
const weights = [...(config.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
const { values, weights } = removeChipWithWeight(
config.values,
config.weights,
index,
);
onUpdate({ values, weights });
}}
placeholder="Type a value and press Enter"
/>
</div>
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Weights (optional)
</p>
<div className="grid gap-2">
{(config.values ?? []).map((value, index) => (
<div key={`${value}-weight`} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground max-w-20 truncate">
{value}
</span>
<Input
type="number"
className="nodrag w-full"
placeholder="Weight"
value={config.weights?.[index] ?? ""}
onChange={(event) => {
const weights = [...(config.weights ?? [])];
weights[index] = event.target.value
? Number(event.target.value)
: null;
onUpdate({ weights });
}}
/>
</div>
))}
</div>
</div>
</div>
<div className="space-y-3 rounded-2xl border border-border/60 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Conditional params (category)
</p>
<span className="text-xs text-muted-foreground">
{Object.keys(conditional).length} rules
</span>
</div>
<div className="flex gap-2">
<Input
id={conditionInputId}
className="nodrag"
placeholder="Condition (e.g., {{ region }} == 'US')"
value={conditionDraft}
onChange={(event) => setConditionDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddCondition();
}
}}
/>
<Button type="button" size="sm" onClick={handleAddCondition}>
Add rule
</Button>
</div>
{Object.entries(conditional).map(([condition, params]) => (
<div
key={condition}
className="space-y-3 rounded-2xl border border-border/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-foreground">{condition}</p>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeCondition(condition)}
>
Remove
</Button>
</div>
<ChipInput
values={params.values ?? []}
onAdd={(value) => {
const values = [...(params.values ?? []), value];
const weights = [...(params.weights ?? []), null];
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
onRemove={(index) => {
const values = [...(params.values ?? [])];
const weights = [...(params.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
placeholder="Type a conditional value and press Enter"
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<div className="rounded-2xl border border-border/60">
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left"
>
<div>
<p className="text-xs font-semibold uppercase text-muted-foreground">
Advanced
</p>
<p className="text-xs text-muted-foreground">
Weights and conditional rules.
</p>
</div>
<span className="text-xs text-muted-foreground">
{advancedOpen ? "Hide" : "Show"}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 border-t border-border/60 p-3">
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Rule weights (optional)
Weights (optional)
</p>
<div className="grid gap-2">
{(params.values ?? []).map((value, index) => (
<div
key={`${condition}-${value}-${index}-weight`}
className="flex items-center gap-3"
>
<span className="text-xs text-muted-foreground w-28 truncate">
{value}
</span>
<Input
type="number"
className="nodrag"
placeholder="Weight"
value={params.weights?.[index] ?? ""}
onChange={(event) => {
const weights = [
...(params.weights ??
Array.from(
{ length: (params.values ?? []).length },
() => null,
)),
];
weights[index] = event.target.value
? Number(event.target.value)
: null;
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, weights },
},
});
}}
/>
</div>
))}
</div>
{(config.values ?? []).length === 0 ? (
<p className="text-xs text-muted-foreground">
Add values first, then set optional weights.
</p>
) : (
<div className="grid gap-2">
{(config.values ?? []).map((value, index) => (
<div key={`${value}-weight`} className="flex items-center gap-3">
<span className="max-w-20 truncate text-xs text-muted-foreground">
{value}
</span>
<Input
type="number"
className="nodrag w-full"
placeholder="Weight"
value={config.weights?.[index] ?? ""}
onChange={(event) => {
const weights = [...(config.weights ?? [])];
weights[index] = event.target.value
? Number(event.target.value)
: null;
onUpdate({ weights });
}}
/>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Conditional params (category)
</p>
<span className="text-xs text-muted-foreground">
{conditionalCount} rules
</span>
</div>
<div className="flex gap-2">
<Input
id={conditionInputId}
className="nodrag"
placeholder="Condition (e.g., {{ region }} == 'US')"
value={conditionDraft}
onChange={(event) => setConditionDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddCondition();
}
}}
/>
<Button type="button" size="sm" onClick={handleAddCondition}>
Add rule
</Button>
</div>
{Object.entries(conditional).map(([condition, params]) => (
<div
key={condition}
className="space-y-3 rounded-2xl border border-border/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-foreground">{condition}</p>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => removeCondition(condition)}
>
Remove
</Button>
</div>
<ChipInput
values={params.values ?? []}
onAdd={(value) => {
const { values, weights } = addChipWithWeight(
params.values,
params.weights,
value,
);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
onRemove={(index) => {
const { values, weights } = removeChipWithWeight(
params.values,
params.weights,
index,
);
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, values, weights },
},
});
}}
placeholder="Type a conditional value and press Enter"
/>
<div className="grid gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Rule weights (optional)
</p>
<div className="grid gap-2">
{(params.values ?? []).map((value, index) => (
<div
key={`${condition}-${value}-${index}-weight`}
className="flex items-center gap-3"
>
<span className="w-28 truncate text-xs text-muted-foreground">
{value}
</span>
<Input
type="number"
className="nodrag"
placeholder="Weight"
value={params.weights?.[index] ?? ""}
onChange={(event) => {
const weights = [
...(params.weights ??
Array.from(
{ length: (params.values ?? []).length },
() => null,
)),
];
weights[index] = event.target.value
? Number(event.target.value)
: null;
onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
conditional_params: {
...conditional,
[condition]: { ...params, weights },
},
});
}}
/>
</div>
))}
</div>
</div>
</div>
))}
</CollapsibleContent>
</div>
</Collapsible>
</div>
);
}

View file

@ -6,8 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { ReactElement } from "react";
import { type ReactElement, useEffect } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
@ -24,13 +23,23 @@ export function PersonDialog({
const sexId = `${config.id}-person-sex`;
const ageRangeId = `${config.id}-person-age-range`;
const cityId = `${config.id}-person-city`;
const sourceId = `${config.id}-person-source`;
const updateField = <K extends keyof SamplerConfig>(
key: K,
value: SamplerConfig[K],
) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
};
useEffect(() => {
if (config.sampler_type !== "person_from_faker") {
onUpdate({
sampler_type: "person_from_faker",
person_with_synthetic_personas: undefined,
});
}
}, [config.sampler_type, onUpdate]);
return (
<div className="space-y-4">
<NameField
@ -38,30 +47,11 @@ export function PersonDialog({
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={sourceId}
>
<div className="rounded-2xl border border-border/60 px-3 py-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Source
</label>
<Select
value={config.sampler_type === "person_from_faker" ? "faker" : "person"}
onValueChange={(value) =>
updateField(
"sampler_type",
value === "faker" ? "person_from_faker" : "person",
)
}
>
<SelectTrigger className="nodrag w-full" id={sourceId}>
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">Managed dataset</SelectItem>
<SelectItem value="faker">Faker</SelectItem>
</SelectContent>
</Select>
</p>
<p className="text-sm text-foreground">Faker</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
@ -137,22 +127,6 @@ export function PersonDialog({
/>
</div>
</div>
{config.sampler_type === "person" && (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-3 py-2">
<div>
<p className="text-sm font-semibold">Synthetic personas</p>
<p className="text-xs text-muted-foreground">
Generate persona profiles.
</p>
</div>
<Switch
checked={config.person_with_synthetic_personas ?? false}
onCheckedChange={(value) =>
updateField("person_with_synthetic_personas", value)
}
/>
</div>
)}
</div>
</div>
);

View file

@ -5,13 +5,19 @@ import {
} from "@/components/ui/dialog";
import type { ReactElement } from "react";
export function DialogShell(): ReactElement {
type DialogShellProps = {
title?: string;
description?: string;
};
export function DialogShell({
title = "Configure block",
description = "Adjust block params before running the flow.",
}: DialogShellProps): ReactElement {
return (
<DialogHeader>
<DialogTitle>Configure block</DialogTitle>
<DialogDescription>
Adjust block params before running the flow.
</DialogDescription>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
);
}

View file

@ -10,6 +10,8 @@ import {
Panel,
ReactFlow,
} from "@xyflow/react";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ReactElement,
useCallback,
@ -157,6 +159,7 @@ export function RecipeStudioPage({
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
null,
);
const [blockSheetOpen, setBlockSheetOpen] = useState(false);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
@ -316,6 +319,11 @@ export function RecipeStudioPage({
setProcessorsOpen(true);
}, [processors, setProcessors]);
const openRootBlockSheet = useCallback(() => {
setSheetView("root");
setBlockSheetOpen(true);
}, [setSheetView]);
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
@ -375,11 +383,37 @@ export function RecipeStudioPage({
size={1}
color="#d4d4d8"
/>
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-4">
<button
type="button"
onClick={openRootBlockSheet}
className="pointer-events-auto corner-squircle flex min-h-36 w-full max-w-md flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/70 bg-background/75 px-6 py-6 text-center backdrop-blur-[1px] transition hover:border-primary/60 hover:bg-background"
>
<div className="flex size-12 items-center justify-center rounded-xl border border-border/70 bg-muted/40">
<HugeiconsIcon
icon={PlusSignIcon}
className="size-6 text-muted-foreground"
/>
</div>
<div>
<p className="text-sm font-semibold text-foreground">
Add your first block
</p>
<p className="text-xs text-muted-foreground">
Click to open block library.
</p>
</div>
</button>
</div>
)}
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
open={blockSheetOpen}
onOpenChange={setBlockSheetOpen}
onAddSampler={addSamplerNode}
onAddSeed={addSeedNode}
onAddLlm={addLlmNode}

View file

@ -27,8 +27,8 @@ export function makeSamplerConfig(
sampler_type: "category",
name,
drop: false,
values: ["A", "B", "C"],
weights: [null, null, null],
values: [],
weights: [],
};
}
if (samplerType === "subcategory") {
@ -42,12 +42,7 @@ export function makeSamplerConfig(
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: "",
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: {
// biome-ignore lint/style/useNamingConvention: sample values
A: ["A1", "A2"],
// biome-ignore lint/style/useNamingConvention: sample values
B: ["B1", "B2"],
},
subcategory_mapping: {},
};
}
if (samplerType === "uniform") {
@ -131,7 +126,7 @@ export function makeSamplerConfig(
uuid_format: "",
};
}
if (samplerType === "person_from_faker") {
if (samplerType === "person" || samplerType === "person_from_faker") {
return {
id,
kind: "sampler",
@ -153,7 +148,7 @@ export function makeSamplerConfig(
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person",
sampler_type: "person_from_faker",
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
@ -164,8 +159,6 @@ export function makeSamplerConfig(
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas: false,
};
}
@ -191,7 +184,7 @@ export function makeLlmConfig(
name,
drop: false,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: "allenai/olmo-3.1-32b-instruct",
model_alias: "",
prompt:
llmType === "judge"
? "Evaluate the content using the scoring criteria below."

View file

@ -153,14 +153,12 @@ function buildSamplerParams(
if (config.person_locale?.trim()) {
params.locale = config.person_locale.trim();
}
if (config.sampler_type === "person") {
if (isValidSex(config.person_sex?.trim())) {
params.sex = config.person_sex?.trim();
} else if (config.person_sex?.trim()) {
if (config.person_sex?.trim()) {
if (isValidSex(config.person_sex.trim())) {
params.sex = config.person_sex.trim();
} else {
errors.push(`Person ${config.name}: sex must be Male or Female.`);
}
} else if (config.person_sex?.trim()) {
params.sex = config.person_sex.trim();
}
if (config.person_city?.trim()) {
params.city = config.person_city.trim();
@ -174,11 +172,6 @@ function buildSamplerParams(
errors.push(`Person ${config.name}: age range must be like 18-70.`);
}
}
if (config.sampler_type === "person") {
// biome-ignore lint/style/useNamingConvention: api schema
params.with_synthetic_personas =
config.person_with_synthetic_personas ?? undefined;
}
return params;
}

View file

@ -108,7 +108,10 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
if (config.sampler_type === "subcategory" && !config.subcategory_parent) {
errors.push("Subcategory needs a parent category column.");
}
if (config.sampler_type === "person") {
if (
config.sampler_type === "person" ||
config.sampler_type === "person_from_faker"
) {
if (config.person_sex?.trim()) {
const normalized = config.person_sex.trim();
if (!isValidSex(normalized)) {
@ -122,14 +125,6 @@ export function getConfigErrors(config: NodeConfig | null): string[] {
}
}
}
if (config.sampler_type === "person_from_faker") {
if (config.person_age_range?.trim()) {
const parsed = parseAgeRange(config.person_age_range);
if (!parsed) {
errors.push("Person age range must be like 18-70.");
}
}
}
}
if (config.kind === "llm") {
if (!config.model_alias.trim()) {