add canvaslab

This commit is contained in:
shine1i 2026-02-04 13:22:41 +01:00
commit 931891b207
25 changed files with 4973 additions and 2319 deletions

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@
"@tanstack/react-router": "^1.156.0",
"@toolwind/corner-shape": "^0.0.8-3",
"@types/canvas-confetti": "^1.9.0",
"@xyflow/react": "^12.10.0",
"assistant-stream": "^0.3.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",

View file

@ -1,10 +1,11 @@
import { createRouter } from "@tanstack/react-router";
import { Route as rootRoute } from "./routes/__root";
import { Route as canvasLabRoute } from "./routes/canvas-lab";
import { Route as chatRoute } from "./routes/chat";
import { Route as exportRoute } from "./routes/export";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as homeRoute } from "./routes/home";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as exportRoute } from "./routes/export";
import { Route as studioRoute } from "./routes/studio";
const routeTree = rootRoute.addChildren([
@ -14,6 +15,7 @@ const routeTree = rootRoute.addChildren([
studioRoute,
chatRoute,
exportRoute,
canvasLabRoute,
]);
export const router = createRouter({ routeTree });

View file

@ -0,0 +1,15 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { Route as rootRoute } from "./__root";
const CanvasLabPage = lazy(() =>
import("@/features/canvas-lab").then((m) => ({
default: m.CanvasLabPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/canvas-lab",
component: CanvasLabPage,
});

View file

@ -0,0 +1,28 @@
const DEFAULT_BASE = "http://127.0.0.1:8000";
export const CANVAS_LAB_API_BASE =
import.meta.env.VITE_DATA_DESIGNER_API ?? DEFAULT_BASE;
type PreviewResponse = {
dataset?: unknown[];
processorArtifacts?: Record<string, unknown>;
};
export async function previewCanvas(
payload: unknown,
): Promise<PreviewResponse> {
const response = await fetch(`${CANVAS_LAB_API_BASE}/preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Preview request failed.");
}
return response.json();
}

View file

@ -0,0 +1,200 @@
import {
Background,
BackgroundVariant,
type Node,
type NodeTypes,
Panel,
ReactFlow,
} from "@xyflow/react";
import { type ReactElement, useCallback, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EyeIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { previewCanvas } from "./api";
import { BlockSheet } from "./components/block-sheet";
import { CanvasNode } from "./components/canvas-node";
import { ConfigDialog } from "./dialogs/config-dialog";
import { useCanvasLabStore } from "./stores/canvas-lab";
import type { CanvasNodeData, SamplerConfig } from "./types";
import { isCategoryConfig } from "./utils";
import { buildCanvasPayload } from "./utils/payload";
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
export function CanvasLabPage(): ReactElement {
const {
nodes,
edges,
configs,
sheetView,
activeConfigId,
dialogOpen,
onNodesChange,
onEdgesChange,
onConnect,
addSamplerNode,
addLlmNode,
openConfig,
updateConfig,
isValidConnection,
setSheetView,
setDialogOpen,
} = useCanvasLabStore(
useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
configs: state.configs,
sheetView: state.sheetView,
activeConfigId: state.activeConfigId,
dialogOpen: state.dialogOpen,
onNodesChange: state.onNodesChange,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
addSamplerNode: state.addSamplerNode,
addLlmNode: state.addLlmNode,
openConfig: state.openConfig,
updateConfig: state.updateConfig,
isValidConnection: state.isValidConnection,
setSheetView: state.setSheetView,
setDialogOpen: state.setDialogOpen,
})),
);
const [sheetContainer, setSheetContainer] = useState<HTMLDivElement | null>(
null,
);
const [previewLoading, setPreviewLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState<{
tone: "success" | "error";
text: string;
} | null>(null);
const handleNodeClick = useCallback(
(_: unknown, node: Node<CanvasNodeData>) => {
openConfig(node.id);
},
[openConfig],
);
const config = activeConfigId ? configs[activeConfigId] : null;
const categoryOptions = useMemo<SamplerConfig[]>(
() => Object.values(configs).filter(isCategoryConfig),
[configs],
);
const handlePreview = async (): Promise<void> => {
setPreviewLoading(true);
setStatusMessage(null);
try {
const { payload, errors } = buildCanvasPayload(configs, nodes, edges);
if (errors.length > 0) {
setStatusMessage({
tone: "error",
text: errors[0] ?? "Fix config errors before preview.",
});
return;
}
const result = await previewCanvas(payload);
const rows = Array.isArray(result.dataset) ? result.dataset.length : 0;
setStatusMessage({
tone: "success",
text: `Preview ready (${rows} rows).`,
});
} catch (error) {
setStatusMessage({
tone: "error",
text: error instanceof Error ? error.message : "Preview failed.",
});
} finally {
setPreviewLoading(false);
}
};
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[1fr_auto] lg:items-center">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Canvas Lab
</h1>
<p className="text-sm text-muted-foreground">
Minimal React Flow canvas.
</p>
{statusMessage && (
<p
className={
statusMessage.tone === "success"
? "mt-2 text-xs text-emerald-600"
: "mt-2 text-xs text-rose-600"
}
>
{statusMessage.text}
</p>
)}
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
onClick={handlePreview}
disabled={previewLoading}
className="gap-2 text-xs"
>
{previewLoading ? (
<Spinner className="size-3.5" />
) : (
<HugeiconsIcon icon={EyeIcon} className="size-3.5" />
)}
Preview
</Button>
</div>
</div>
</div>
<div
className="relative h-[75vh] w-full rounded-3xl border border-border/60 bg-white shadow-sm"
ref={setSheetContainer}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
fitView={true}
className="h-full w-full"
>
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
view={sheetView}
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
/>
</Panel>
</ReactFlow>
</div>
</main>
<ConfigDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
config={config}
categoryOptions={categoryOptions}
onUpdate={updateConfig}
/>
</div>
);
}

View file

@ -0,0 +1,243 @@
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
ArrowLeft02Icon,
ArrowRight01Icon,
CodeIcon,
Database02Icon,
Flowchart01Icon,
PlusSignIcon,
SparklesIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { LlmType, SamplerType } from "../types";
type SheetView = "root" | "sampler" | "llm";
type BlockSheetProps = {
container: HTMLDivElement | null;
view: SheetView;
onViewChange: (view: SheetView) => void;
onAddSampler: (type: SamplerType) => void;
onAddLlm: (type: LlmType) => void;
};
function getSheetTitle(view: SheetView): string {
if (view === "root") {
return "Add a block";
}
if (view === "sampler") {
return "Sampler blocks";
}
return "LLM blocks";
}
const MAIN_SHEET_ITEMS = [
{
kind: "sampler" as const,
title: "Sampler",
description: "Numeric + categorical blocks.",
icon: Database02Icon,
},
{
kind: "llm" as const,
title: "LLM",
description: "Text + structured blocks.",
icon: SparklesIcon,
},
];
const SAMPLER_ITEMS = [
{
type: "category" as const,
title: "Category",
description: "Pick from a list of values.",
icon: Database02Icon,
},
{
type: "subcategory" as const,
title: "Subcategory",
description: "Map sub-values to a category.",
icon: Database02Icon,
},
{
type: "uniform" as const,
title: "Uniform",
description: "Random number between low/high.",
icon: Database02Icon,
},
{
type: "gaussian" as const,
title: "Gaussian",
description: "Normal distribution sampler.",
icon: Database02Icon,
},
{
type: "datetime" as const,
title: "Datetime",
description: "Date/time range sampler.",
icon: Database02Icon,
},
{
type: "uuid" as const,
title: "UUID",
description: "UUID string sampler.",
icon: Database02Icon,
},
{
type: "person" as const,
title: "Person",
description: "Synthetic person sampler.",
icon: Database02Icon,
},
];
const LLM_ITEMS = [
{
type: "text" as const,
title: "LLM Text",
description: "Free-form prompt generation.",
icon: SparklesIcon,
},
{
type: "structured" as const,
title: "LLM Structured",
description: "JSON output via schema.",
icon: Flowchart01Icon,
},
{
type: "code" as const,
title: "LLM Code",
description: "Generate code or SQL.",
icon: CodeIcon,
},
];
export function BlockSheet({
container,
view,
onViewChange,
onAddSampler,
onAddLlm,
}: BlockSheetProps): ReactElement {
const title = getSheetTitle(view);
return (
<Sheet>
<SheetTrigger asChild={true}>
<Button size="icon-sm" variant="secondary">
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
</Button>
</SheetTrigger>
<SheetContent
side="right"
container={container}
className="absolute gap-0 p-0 shadow-none"
overlayClassName="absolute inset-0 bg-transparent backdrop-blur-0 supports-backdrop-filter:backdrop-blur-0 data-open:fade-in-0 data-closed:fade-out-0"
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<div className="flex items-center gap-2">
{view !== "root" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onViewChange("root")}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
</Button>
)}
<SheetTitle>{title}</SheetTitle>
</div>
</SheetHeader>
<div className="px-6 py-4">
<div className="mt-4 flex flex-col gap-2">
{view === "root" &&
MAIN_SHEET_ITEMS.map((item) => (
<button
key={item.kind}
type="button"
onClick={() =>
onViewChange(item.kind === "sampler" ? "sampler" : "llm")
}
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
>
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
<HugeiconsIcon icon={item.icon} className="size-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-foreground">
{item.title}
</p>
<p className="text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
</button>
))}
{view === "sampler" &&
SAMPLER_ITEMS.map((item) => (
<button
key={item.type}
type="button"
onClick={() => onAddSampler(item.type)}
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
>
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
<HugeiconsIcon icon={item.icon} className="size-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-foreground">
{item.title}
</p>
<p className="text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
</button>
))}
{view === "llm" &&
LLM_ITEMS.map((item) => (
<button
key={item.type}
type="button"
onClick={() => onAddLlm(item.type)}
className="flex w-full items-center gap-3 rounded-2xl border border-border/60 bg-white px-3 py-3 text-left transition hover:border-border hover:bg-muted/40"
>
<div className="flex size-9 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground">
<HugeiconsIcon icon={item.icon} className="size-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-foreground">
{item.title}
</p>
<p className="text-[11px] text-muted-foreground">
{item.description}
</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
</button>
))}
</div>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,64 @@
import { cn } from "@/lib/utils";
import { Database02Icon, SparklesIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { NodeProps } from "@xyflow/react";
import { Handle, Position } from "@xyflow/react";
import { type ReactElement, memo } from "react";
import type { CanvasNode as CanvasNodeType } from "../types";
const NODE_META = {
sampler: {
icon: Database02Icon,
tone: "bg-emerald-50 text-emerald-600 border-emerald-100",
},
llm: {
icon: SparklesIcon,
tone: "bg-purple-50 text-purple-600 border-purple-100",
},
} as const;
function CanvasNodeBase({
data,
selected,
}: NodeProps<CanvasNodeType>): ReactElement {
const meta = NODE_META[data.kind];
return (
<div
className={cn(
"rounded-2xl border bg-white px-4 py-3 shadow-sm min-w-[180px]",
selected
? "border-foreground/40 ring-1 ring-foreground/10"
: "border-border/60",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex size-9 items-center justify-center rounded-xl border",
meta.tone,
)}
>
<HugeiconsIcon icon={meta.icon} className="size-4" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">{data.title}</p>
<p className="text-[11px] text-muted-foreground">
{data.subtype} · {data.name}
</p>
</div>
</div>
<Handle
type="target"
position={Position.Left}
className="size-2 border border-border bg-white"
/>
<Handle
type="source"
position={Position.Right}
className="size-2 border border-border bg-white"
/>
</div>
);
}
export const CanvasNode = memo(CanvasNodeBase);

View file

@ -0,0 +1,110 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
import type { ReactElement } from "react";
import type { NodeConfig, SamplerConfig } from "../types";
import { LlmDialog } from "./llm/llm-dialog";
import { CategoryDialog } from "./samplers/category-dialog";
import { DatetimeDialog } from "./samplers/datetime-dialog";
import { GaussianDialog } from "./samplers/gaussian-dialog";
import { PersonDialog } from "./samplers/person-dialog";
import { SubcategoryDialog } from "./samplers/subcategory-dialog";
import { UniformDialog } from "./samplers/uniform-dialog";
import { UuidDialog } from "./samplers/uuid-dialog";
import { DialogShell } from "./shared/dialog-shell";
import { ValidationBanner } from "./shared/validation-banner";
type ConfigDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
config: NodeConfig | null;
categoryOptions: SamplerConfig[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
};
export function ConfigDialog({
open,
onOpenChange,
config,
categoryOptions,
onUpdate,
}: ConfigDialogProps): ReactElement {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogShell />
{!config && (
<div className="text-sm text-muted-foreground">
Select a node to edit.
</div>
)}
{config && (
<div className="space-y-4">
<ValidationBanner config={config} />
{config.kind === "sampler" &&
config.sampler_type === "category" && (
<CategoryDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "subcategory" && (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "uniform" && (
<UniformDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "gaussian" && (
<GaussianDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" &&
config.sampler_type === "datetime" && (
<DatetimeDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "uuid" && (
<UuidDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "sampler" && config.sampler_type === "person" && (
<PersonDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
{config.kind === "llm" && (
<LlmDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,143 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { LlmConfig } from "../../types";
import { NameField } from "../shared/name-field";
const CODE_LANG_OPTIONS = [
"python",
"javascript",
"typescript",
"java",
"kotlin",
"go",
"rust",
"ruby",
"scala",
"swift",
"sql:sqlite",
"sql:postgres",
"sql:mysql",
"sql:tsql",
"sql:bigquery",
"sql:ansi",
];
type LlmDialogProps = {
config: LlmConfig;
onUpdate: (patch: Partial<LlmConfig>) => void;
};
export function LlmDialog({ config, onUpdate }: LlmDialogProps): ReactElement {
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
const outputFormatId = `${config.id}-output-format`;
const systemPromptId = `${config.id}-system-prompt`;
const updateField = <K extends keyof LlmConfig>(
key: K,
value: LlmConfig[K],
) => {
onUpdate({ [key]: value } as Partial<LlmConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={modelAliasId}
>
Model alias
</label>
<Input
id={modelAliasId}
className="nodrag"
value={config.model_alias}
onChange={(event) => updateField("model_alias", event.target.value)}
/>
</div>
{config.llm_type === "code" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={codeLangId}
>
Code language
</label>
<Select
value={config.code_lang ?? "python"}
onValueChange={(value) => updateField("code_lang", value)}
>
<SelectTrigger className="nodrag w-full" id={codeLangId}>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{CODE_LANG_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={promptId}
>
Prompt
</label>
<Textarea
id={promptId}
className="nodrag"
value={config.prompt}
onChange={(event) => updateField("prompt", event.target.value)}
/>
</div>
{config.llm_type === "structured" && (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={outputFormatId}
>
Output format (JSON schema)
</label>
<Textarea
id={outputFormatId}
className="nodrag"
value={config.output_format ?? ""}
onChange={(event) =>
updateField("output_format", event.target.value)
}
/>
</div>
)}
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={systemPromptId}
>
System prompt (optional)
</label>
<Textarea
id={systemPromptId}
className="nodrag"
value={config.system_prompt}
onChange={(event) => updateField("system_prompt", event.target.value)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { type ReactElement, useEffect, useState } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type CategoryDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function CategoryDialog({
config,
onUpdate,
}: CategoryDialogProps): ReactElement {
const [valueDraft, setValueDraft] = useState("");
const valuesInputId = `${config.id}-values`;
useEffect(() => {
if (config.id) {
setValueDraft("");
}
}, [config.id]);
const handleAddValue = () => {
const nextValue = valueDraft.trim();
if (!nextValue) {
return;
}
const values = config.values ? [...config.values] : [];
const weights = config.weights ? [...config.weights] : [];
values.push(nextValue);
weights.push(null);
onUpdate({ values, weights });
setValueDraft("");
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="space-y-3">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={valuesInputId}
>
Values
</label>
<div className="flex gap-2">
<Input
id={valuesInputId}
className="nodrag"
placeholder="Add a value"
value={valueDraft}
onChange={(event) => setValueDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddValue();
}
}}
/>
<Button type="button" size="sm" onClick={handleAddValue}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(config.values ?? []).map((value, index) => (
<Badge key={value} variant="secondary">
<span>{value}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const values = [...(config.values ?? [])];
const weights = [...(config.weights ?? [])];
values.splice(index, 1);
weights.splice(index, 1);
onUpdate({ values, weights });
}}
>
×
</button>
</Badge>
))}
</div>
</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 w-20 truncate">
{value}
</span>
<Input
type="number"
className="nodrag"
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>
);
}

View file

@ -0,0 +1,110 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
const DATETIME_UNITS = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
type DatetimeDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function DatetimeDialog({
config,
onUpdate,
}: DatetimeDialogProps): ReactElement {
const startId = `${config.id}-datetime-start`;
const endId = `${config.id}-datetime-end`;
const unitId = `${config.id}-datetime-unit`;
const updateField = <K extends keyof SamplerConfig>(
key: K,
value: SamplerConfig[K],
) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={startId}
>
Start
</label>
<Input
id={startId}
type="datetime-local"
className="nodrag"
value={config.datetime_start ?? ""}
onChange={(event) =>
updateField("datetime_start", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={endId}
>
End
</label>
<Input
id={endId}
type="datetime-local"
className="nodrag"
value={config.datetime_end ?? ""}
onChange={(event) =>
updateField("datetime_end", event.target.value)
}
/>
</div>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={unitId}
>
Unit
</label>
<Select
value={config.datetime_unit ?? ""}
onValueChange={(value) => updateField("datetime_unit", value)}
>
<SelectTrigger className="nodrag w-full" id={unitId}>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
{DATETIME_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type GaussianDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function GaussianDialog({
config,
onUpdate,
}: GaussianDialogProps): ReactElement {
const meanId = `${config.id}-gaussian-mean`;
const stdId = `${config.id}-gaussian-std`;
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={meanId}
>
Mean
</label>
<Input
id={meanId}
type="number"
className="nodrag"
value={config.mean ?? ""}
onChange={(event) => onUpdate({ mean: event.target.value })}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={stdId}
>
Std
</label>
<Input
id={stdId}
type="number"
className="nodrag"
value={config.std ?? ""}
onChange={(event) => onUpdate({ std: event.target.value })}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,130 @@
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type PersonDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function PersonDialog({
config,
onUpdate,
}: PersonDialogProps): ReactElement {
const localeId = `${config.id}-person-locale`;
const sexId = `${config.id}-person-sex`;
const ageRangeId = `${config.id}-person-age-range`;
const cityId = `${config.id}-person-city`;
const updateField = <K extends keyof SamplerConfig>(
key: K,
value: SamplerConfig[K],
) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={localeId}
>
Locale
</label>
<Input
id={localeId}
className="nodrag"
value={config.person_locale ?? ""}
onChange={(event) =>
updateField("person_locale", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={sexId}
>
Sex
</label>
<Input
id={sexId}
className="nodrag"
value={config.person_sex ?? ""}
onChange={(event) =>
updateField("person_sex", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={ageRangeId}
>
Age range
</label>
<Input
id={ageRangeId}
className="nodrag"
value={config.person_age_range ?? ""}
onChange={(event) =>
updateField("person_age_range", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={cityId}
>
City
</label>
<Input
id={cityId}
className="nodrag"
value={config.person_city ?? ""}
onChange={(event) =>
updateField("person_city", event.target.value)
}
/>
</div>
</div>
<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 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">Sample dataset</p>
<p className="text-xs text-muted-foreground">
Use dataset when available.
</p>
</div>
<Switch
checked={config.person_sample_dataset_when_available ?? false}
onCheckedChange={(value) =>
updateField("person_sample_dataset_when_available", value)
}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,196 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type SubcategoryDialogProps = {
config: SamplerConfig;
categoryOptions: SamplerConfig[];
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function SubcategoryDialog({
config,
categoryOptions,
onUpdate,
}: SubcategoryDialogProps): ReactElement {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const parentSelectId = `${config.id}-parent-category`;
const updateField = useCallback(
<K extends keyof SamplerConfig>(key: K, value: SamplerConfig[K]) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
},
[onUpdate],
);
const parent = useMemo(
() =>
categoryOptions.find(
(option) => option.name === config.subcategory_parent,
) ?? null,
[categoryOptions, config.subcategory_parent],
);
const categoryValues = parent?.values ?? [];
const mapping = config.subcategory_mapping ?? {};
const ensureMapping = useCallback(
(nextParent?: SamplerConfig | null) => {
const values = nextParent?.values ?? [];
const nextMapping: Record<string, string[]> = {};
for (const value of values) {
nextMapping[value] = config.subcategory_mapping?.[value] ?? [];
}
const currentKeys = Object.keys(config.subcategory_mapping ?? {});
const nextKeys = Object.keys(nextMapping);
const changed =
currentKeys.length !== nextKeys.length ||
currentKeys.some((key) => !nextKeys.includes(key));
if (changed) {
updateField("subcategory_mapping", nextMapping);
}
},
[config.subcategory_mapping, updateField],
);
useEffect(() => {
if (parent) {
ensureMapping(parent);
}
}, [ensureMapping, parent]);
const addSubValue = (categoryValue: string) => {
const draft = drafts[categoryValue]?.trim();
if (!draft) {
return;
}
const next = { ...mapping };
const list = next[categoryValue] ? [...next[categoryValue]] : [];
list.push(draft);
next[categoryValue] = list;
updateField("subcategory_mapping", next);
setDrafts((prev) => ({ ...prev, [categoryValue]: "" }));
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="space-y-3">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={parentSelectId}
>
Parent category column
</label>
<Select
value={config.subcategory_parent ?? ""}
onValueChange={(value) => {
const nextParent =
categoryOptions.find((option) => option.name === value) ?? null;
updateField("subcategory_parent", value);
ensureMapping(nextParent);
}}
>
<SelectTrigger className="nodrag w-full" id={parentSelectId}>
<SelectValue placeholder="Select category column" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((option) => (
<SelectItem key={option.id} value={option.name}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{categoryValues.length > 0 && (
<div className="grid gap-4">
{categoryValues.map((value) => (
<div
key={value}
className="rounded-2xl border border-border/60 p-3"
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">
{value}
</p>
<span className="text-xs text-muted-foreground">
{mapping[value]?.length ?? 0} subvalues
</span>
</div>
<div className="mt-3 flex gap-2">
<Input
className="nodrag"
placeholder="Add subcategory"
value={drafts[value] ?? ""}
onChange={(event) =>
setDrafts((prev) => ({
...prev,
[value]: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addSubValue(value);
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => addSubValue(value)}
>
Add
</Button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{(mapping[value] ?? []).map((item, index) => (
<Badge key={`${value}-${item}`} variant="secondary">
<span>{item}</span>
<button
type="button"
className="ml-2 text-xs"
onClick={() => {
const next = { ...mapping };
const list = [...(next[value] ?? [])];
list.splice(index, 1);
next[value] = list;
updateField("subcategory_mapping", next);
}}
>
×
</button>
</Badge>
))}
</div>
{(mapping[value] ?? []).length === 0 && (
<p className="mt-2 text-xs text-rose-500">
Add at least 1 subcategory.
</p>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type UniformDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function UniformDialog({
config,
onUpdate,
}: UniformDialogProps): ReactElement {
const lowId = `${config.id}-uniform-low`;
const highId = `${config.id}-uniform-high`;
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={lowId}
>
Low
</label>
<Input
id={lowId}
type="number"
className="nodrag"
value={config.low ?? ""}
onChange={(event) => onUpdate({ low: event.target.value })}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={highId}
>
High
</label>
<Input
id={highId}
type="number"
className="nodrag"
value={config.high ?? ""}
onChange={(event) => onUpdate({ high: event.target.value })}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { SamplerConfig } from "../../types";
import { NameField } from "../shared/name-field";
type UuidDialogProps = {
config: SamplerConfig;
onUpdate: (patch: Partial<SamplerConfig>) => void;
};
export function UuidDialog({
config,
onUpdate,
}: UuidDialogProps): ReactElement {
const uuidId = `${config.id}-uuid-format`;
const updateField = <K extends keyof SamplerConfig>(
key: K,
value: SamplerConfig[K],
) => {
onUpdate({ [key]: value } as Partial<SamplerConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={uuidId}
>
UUID format (optional)
</label>
<Input
id={uuidId}
className="nodrag"
value={config.uuid_format ?? ""}
onChange={(event) => updateField("uuid_format", event.target.value)}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import {
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ReactElement } from "react";
export function DialogShell(): ReactElement {
return (
<DialogHeader>
<DialogTitle>Configure block</DialogTitle>
<DialogDescription>
Adjust block params before running the flow.
</DialogDescription>
</DialogHeader>
);
}

View file

@ -0,0 +1,33 @@
import { Input } from "@/components/ui/input";
import { type ReactElement, useId } from "react";
type NameFieldProps = {
id?: string;
value: string;
onChange: (value: string) => void;
};
export function NameField({
id,
value,
onChange,
}: NameFieldProps): ReactElement {
const fallbackId = useId();
const inputId = id ?? fallbackId;
return (
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={inputId}
>
Column name
</label>
<Input
id={inputId}
className="nodrag"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
}

View file

@ -0,0 +1,24 @@
import type { ReactElement } from "react";
import type { NodeConfig } from "../../types";
import { getConfigErrors } from "../../utils";
export function ValidationBanner({
config,
}: {
config: NodeConfig | null;
}): ReactElement | null {
const errors = getConfigErrors(config);
if (errors.length === 0) {
return null;
}
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
<p className="font-semibold">Fix before run</p>
<ul className="mt-1 list-disc pl-4">
{errors.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1 @@
export { CanvasLabPage } from "./canvas-lab-page";

View file

@ -0,0 +1,343 @@
import {
type Connection,
type Edge,
type EdgeChange,
type IsValidConnection,
type NodeChange,
addEdge,
applyEdgeChanges,
applyNodeChanges,
} from "@xyflow/react";
import { create } from "zustand";
import type {
CanvasNode,
LlmType,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
import {
isCategoryConfig,
isSubcategoryConfig,
makeLlmConfig,
makeSamplerConfig,
nodeDataFromConfig,
} from "../utils";
type SheetView = "root" | "sampler" | "llm";
type CanvasLabState = {
nodes: CanvasNode[];
edges: Edge[];
configs: Record<string, NodeConfig>;
sheetView: SheetView;
activeConfigId: string | null;
dialogOpen: boolean;
nextId: number;
nextY: number;
setSheetView: (view: SheetView) => void;
setDialogOpen: (open: boolean) => void;
openConfig: (id: string) => void;
addSamplerNode: (type: SamplerType) => void;
addLlmNode: (type: LlmType) => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
onNodesChange: (changes: NodeChange<CanvasNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => void;
onConnect: (connection: Connection) => void;
isValidConnection: IsValidConnection;
};
function updateNodeData(
nodes: CanvasNode[],
id: string,
config: NodeConfig,
): CanvasNode[] {
return nodes.map((node) =>
node.id === id ? { ...node, data: nodeDataFromConfig(config) } : node,
);
}
function buildPromptWithRef(prompt: string, ref: string): string {
if (prompt.includes(ref)) {
return prompt;
}
if (prompt.trim()) {
return `${prompt}\n${ref}`;
}
return ref;
}
function findNodeIdByName(
configs: Record<string, NodeConfig>,
name: string,
): string | null {
const entry = Object.entries(configs).find(
([, config]) => config.name === name,
);
return entry ? entry[0] : null;
}
function syncSubcategoryMapping(
subcategory: SamplerConfig,
parent: NodeConfig,
): SamplerConfig {
if (!isCategoryConfig(parent)) {
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
};
}
const nextMapping: Record<string, string[]> = {
...(subcategory.subcategory_mapping ?? {}),
};
for (const value of parent.values ?? []) {
if (!nextMapping[value]) {
nextMapping[value] = [];
}
}
return {
...subcategory,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: parent.name,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: nextMapping,
};
}
export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
nodes: [],
edges: [],
configs: {},
sheetView: "root",
activeConfigId: null,
dialogOpen: false,
nextId: 3,
nextY: 280,
setSheetView: (view) => set({ sheetView: view }),
setDialogOpen: (open) => set({ dialogOpen: open }),
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
addSamplerNode: (type) => {
set((state) => {
const id = `n${state.nextId}`;
const existing = Object.values(state.configs);
const config = makeSamplerConfig(id, type, existing);
const node: CanvasNode = {
id,
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config),
};
return {
configs: { ...state.configs, [id]: config },
nodes: [...state.nodes, node],
nextId: state.nextId + 1,
nextY: state.nextY + 140,
activeConfigId: id,
dialogOpen: true,
};
});
},
addLlmNode: (type) => {
set((state) => {
const id = `n${state.nextId}`;
const existing = Object.values(state.configs);
const config = makeLlmConfig(id, type, existing);
const node: CanvasNode = {
id,
type: "builder",
position: { x: 0, y: state.nextY },
data: nodeDataFromConfig(config),
};
return {
configs: { ...state.configs, [id]: config },
nodes: [...state.nodes, node],
nextId: state.nextId + 1,
nextY: state.nextY + 140,
activeConfigId: id,
dialogOpen: true,
};
});
},
updateConfig: (id, patch) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyUpdate = (state: CanvasLabState) => {
const current = state.configs[id];
if (!current) {
return state;
}
const next = { ...current, ...patch } as NodeConfig;
let configs: Record<string, NodeConfig> = {
...state.configs,
[id]: next,
};
const nodes = updateNodeData(state.nodes, id, next);
let edges = state.edges;
const hasParentPatch = Object.prototype.hasOwnProperty.call(
patch,
"subcategory_parent",
);
if (isSubcategoryConfig(current) && hasParentPatch) {
const nextParent =
(patch as Partial<SamplerConfig>).subcategory_parent ?? "";
const parentId = nextParent
? findNodeIdByName(configs, nextParent)
: null;
edges = edges.filter((edge) => edge.target !== id);
if (parentId) {
edges = addEdge(
{
source: parentId,
target: id,
sourceHandle: null,
targetHandle: null,
},
edges,
);
}
}
if (isCategoryConfig(current)) {
const oldName = current.name;
const nextCategory = isCategoryConfig(next) ? next : current;
const newName = nextCategory.name;
const oldValues = current.values ?? [];
const newValues = nextCategory.values ?? [];
const nameChanged = oldName !== newName;
const valuesChanged =
oldValues.length !== newValues.length ||
oldValues.some((value, index) => value !== newValues[index]);
for (const config of Object.values(configs)) {
if (!isSubcategoryConfig(config)) {
continue;
}
if (config.subcategory_parent !== oldName) {
continue;
}
const mapping = config.subcategory_mapping ?? {};
const nextMapping: Record<string, string[]> = {};
for (const value of newValues) {
nextMapping[value] = mapping[value] ?? [];
}
const updated: NodeConfig = {
...config,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent: nameChanged
? newName
: config.subcategory_parent,
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping: valuesChanged ? nextMapping : mapping,
};
configs = { ...configs, [config.id]: updated };
}
}
return { configs, nodes, edges };
};
set(applyUpdate);
},
onNodesChange: (changes) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyNodesChange = (state: CanvasLabState) => {
const removedIds = changes
.filter((change) => change.type === "remove")
.map((change) => change.id);
let edges = state.edges;
let configs = state.configs;
if (removedIds.length > 0) {
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 (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: {},
};
}
}
}
}
const nodes = applyNodeChanges<CanvasNode>(changes, state.nodes);
return { nodes, edges, configs };
};
set(applyNodesChange);
},
onEdgesChange: (changes) => {
set((state) => ({ edges: applyEdgeChanges(changes, state.edges) }));
},
onConnect: (connection) => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: store update
const applyConnect = (state: CanvasLabState) => {
const source = connection.source
? state.configs[connection.source]
: undefined;
const target = connection.target
? state.configs[connection.target]
: undefined;
if (isSubcategoryConfig(target) && !isCategoryConfig(source)) {
return state;
}
const edges = addEdge(connection, state.edges);
if (!(connection.source && connection.target)) {
return { edges };
}
if (!(source && target)) {
return { edges };
}
const sourceName = source.name;
let configs = state.configs;
if (target.kind === "llm") {
const ref = `{{ ${sourceName} }}`;
const nextPrompt = buildPromptWithRef(target.prompt ?? "", ref);
const next = { ...target, prompt: nextPrompt };
configs = { ...configs, [target.id]: next };
return { edges, configs };
}
if (isSubcategoryConfig(target)) {
const next = syncSubcategoryMapping(target, source);
configs = { ...configs, [target.id]: next };
return { edges, configs };
}
return { edges };
};
set(applyConnect);
},
isValidConnection: (connection) => {
if (!(connection.source && connection.target)) {
return false;
}
const configs = get().configs;
const source = configs[connection.source];
const target = configs[connection.target];
if (isSubcategoryConfig(target)) {
return isCategoryConfig(source);
}
return connection.source !== connection.target;
},
}));

View file

@ -0,0 +1,78 @@
import type { Node } from "@xyflow/react";
export type SamplerType =
| "category"
| "subcategory"
| "uniform"
| "gaussian"
| "datetime"
| "uuid"
| "person";
export type LlmType = "text" | "structured" | "code";
export type CanvasNodeData = {
title: string;
name: string;
kind: "sampler" | "llm";
subtype: string;
};
export type CanvasNode = Node<CanvasNodeData, "builder">;
export type SamplerConfig = {
id: string;
kind: "sampler";
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: SamplerType;
name: string;
values?: string[];
weights?: Array<number | null>;
low?: string;
high?: string;
mean?: string;
std?: string;
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start?: string;
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end?: string;
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit?: string;
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format?: string;
// biome-ignore lint/style/useNamingConvention: api schema
person_locale?: string;
// biome-ignore lint/style/useNamingConvention: api schema
person_sex?: string;
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range?: string;
// biome-ignore lint/style/useNamingConvention: api schema
person_city?: string;
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
person_sample_dataset_when_available?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_parent?: string;
// biome-ignore lint/style/useNamingConvention: api schema
subcategory_mapping?: Record<string, string[]>;
};
export type LlmConfig = {
id: string;
kind: "llm";
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: LlmType;
name: string;
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: string;
prompt: string;
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: string;
// biome-ignore lint/style/useNamingConvention: api schema
code_lang?: string;
// biome-ignore lint/style/useNamingConvention: api schema
output_format?: string;
};
export type NodeConfig = SamplerConfig | LlmConfig;

View file

@ -0,0 +1,312 @@
import type {
CanvasNodeData,
LlmConfig,
LlmType,
NodeConfig,
SamplerConfig,
SamplerType,
} from "../types";
const SAMPLER_LABELS: Record<SamplerType, string> = {
category: "Category",
subcategory: "Subcategory",
uniform: "Uniform",
gaussian: "Gaussian",
datetime: "Datetime",
uuid: "UUID",
person: "Person",
};
const LLM_LABELS: Record<LlmType, string> = {
text: "LLM Text",
structured: "LLM Structured",
code: "LLM Code",
};
export function nextName(existing: NodeConfig[], prefix: string): string {
const counts = existing
.map((item) => item.name)
.filter((name) => name.startsWith(prefix))
.map((name) => {
const suffix = name.slice(prefix.length);
const num = Number.parseInt(suffix.replace("_", ""), 10);
return Number.isNaN(num) ? 0 : num;
});
const next = counts.length > 0 ? Math.max(...counts) + 1 : 1;
return `${prefix}_${next}`;
}
export function makeSamplerConfig(
id: string,
samplerType: SamplerType,
existing: NodeConfig[],
): SamplerConfig {
const namePrefix =
samplerType === "subcategory" ? "subcategory" : samplerType;
const name = nextName(existing, namePrefix);
if (samplerType === "category") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
name,
values: ["A", "B", "C"],
weights: [null, null, null],
};
}
if (samplerType === "subcategory") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "subcategory",
name,
// 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"],
},
};
}
if (samplerType === "uniform") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uniform",
name,
low: "0",
high: "1",
};
}
if (samplerType === "gaussian") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "gaussian",
name,
mean: "0",
std: "1",
};
}
if (samplerType === "datetime") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "datetime",
name,
// biome-ignore lint/style/useNamingConvention: api schema
datetime_start: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_end: "",
// biome-ignore lint/style/useNamingConvention: api schema
datetime_unit: "day",
};
}
if (samplerType === "uuid") {
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "uuid",
name,
// biome-ignore lint/style/useNamingConvention: api schema
uuid_format: "",
};
}
return {
id,
kind: "sampler",
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "person",
name,
// biome-ignore lint/style/useNamingConvention: api schema
person_locale: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_sex: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_age_range: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_city: "",
// biome-ignore lint/style/useNamingConvention: api schema
person_with_synthetic_personas: false,
// biome-ignore lint/style/useNamingConvention: api schema
person_sample_dataset_when_available: false,
};
}
export function makeLlmConfig(
id: string,
llmType: LlmType,
existing: NodeConfig[],
): LlmConfig {
let namePrefix = "llm_text";
if (llmType === "structured") {
namePrefix = "llm_structured";
} else if (llmType === "code") {
namePrefix = "llm_code";
}
const name = nextName(existing, namePrefix);
return {
id,
kind: "llm",
// biome-ignore lint/style/useNamingConvention: api schema
llm_type: llmType,
name,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: "local-text",
prompt: "Write a response about {{ sampler_1 }}.",
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: "",
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: llmType === "code" ? "python" : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
output_format:
llmType === "structured" ? '{\n "field": "string"\n}' : undefined,
};
}
export function labelForSampler(type: SamplerType): string {
return SAMPLER_LABELS[type] ?? "Sampler";
}
export function labelForLlm(type: LlmType): string {
return LLM_LABELS[type] ?? "LLM";
}
export function nodeDataFromConfig(config: NodeConfig): CanvasNodeData {
if (config.kind === "sampler") {
return {
title: "Sampler",
kind: "sampler",
subtype: labelForSampler(config.sampler_type),
name: config.name,
};
}
return {
title: "LLM",
kind: "llm",
subtype: labelForLlm(config.llm_type),
name: config.name,
};
}
export function isSamplerConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(config && config.kind === "sampler");
}
export function isCategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config && config.kind === "sampler" && config.sampler_type === "category",
);
}
export function isSubcategoryConfig(
config: NodeConfig | null | undefined,
): config is SamplerConfig {
return Boolean(
config &&
config.kind === "sampler" &&
config.sampler_type === "subcategory",
);
}
export function isLlmConfig(
config: NodeConfig | null | undefined,
): config is LlmConfig {
return Boolean(config && config.kind === "llm");
}
function parseNumber(value?: string): number | null {
if (!value) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: validation rules
export function getConfigErrors(config: NodeConfig | null): string[] {
if (!config) {
return [];
}
const errors: string[] = [];
if (!config.name.trim()) {
errors.push("Name is required.");
}
if (config.kind === "sampler") {
if (config.sampler_type === "category") {
const values = config.values ?? [];
if (values.length < 2) {
errors.push("Category needs at least 2 values.");
}
const weights = config.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (hasWeights && weights.some((weight) => weight === null)) {
errors.push("Weights must be set for all values.");
}
}
if (config.sampler_type === "uniform") {
const low = parseNumber(config.low);
const high = parseNumber(config.high);
if (low === null || high === null) {
errors.push("Uniform low/high must be numbers.");
} else if (low >= high) {
errors.push("Uniform low must be < high.");
}
}
if (config.sampler_type === "gaussian") {
const mean = parseNumber(config.mean);
const std = parseNumber(config.std);
if (mean === null || std === null) {
errors.push("Gaussian mean/std must be numbers.");
} else if (std <= 0) {
errors.push("Gaussian std must be > 0.");
}
}
if (config.sampler_type === "datetime") {
if (!config.datetime_unit) {
errors.push("Datetime unit required.");
}
if (config.datetime_start && config.datetime_end) {
const start = new Date(config.datetime_start).getTime();
const end = new Date(config.datetime_end).getTime();
if (!(Number.isFinite(start) && Number.isFinite(end))) {
errors.push("Datetime start/end must be valid.");
} else if (start >= end) {
errors.push("Datetime start must be before end.");
}
}
}
if (config.sampler_type === "subcategory" && !config.subcategory_parent) {
errors.push("Subcategory needs a parent category column.");
}
}
if (config.kind === "llm" && !config.prompt.trim()) {
errors.push("Prompt is required.");
}
if (
config.kind === "llm" &&
config.llm_type === "structured" &&
typeof config.output_format === "string" &&
config.output_format.trim()
) {
try {
JSON.parse(config.output_format);
} catch {
errors.push("Output format must be valid JSON.");
}
}
return errors;
}

View file

@ -0,0 +1,315 @@
import type { Edge } from "@xyflow/react";
import type {
CanvasNode,
LlmConfig,
NodeConfig,
SamplerConfig,
} from "../types";
import { getConfigErrors } from "./index";
const DEFAULT_PROVIDER = {
name: "openrouter",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "openai",
endpoint: "https://openrouter.ai/api/v1",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "OPENROUTER_API_KEY",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: {},
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: {},
};
const DEFAULT_CONFIG = {
provider: "openrouter",
model: "stepfun/step-3.5-flash:free",
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters: {
temperature: 0.7,
// biome-ignore lint/style/useNamingConvention: api schema
max_tokens: 256,
},
};
type CanvasPayload = {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: Record<string, unknown>[];
columns: Record<string, unknown>[];
processors: Record<string, unknown>[];
};
run: {
rows: number;
preview: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: string[];
};
ui: {
nodes: { id: string; x: number; y: number }[];
edges: { from: string; to: string }[];
};
};
export type CanvasPayloadResult = {
errors: string[];
payload: CanvasPayload;
};
function parseNumber(value?: string): number | null {
if (!value) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per type logic
function buildSamplerParams(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
if (config.sampler_type === "category") {
const values = config.values ?? [];
const params: Record<string, unknown> = { values };
const weights = config.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (hasWeights && weights.some((weight) => weight === null)) {
errors.push(`Sampler ${config.name}: weights missing values.`);
} else if (hasWeights) {
params.weights = weights.filter((weight) => weight !== null);
}
return params;
}
if (config.sampler_type === "subcategory") {
const mapping = config.subcategory_mapping ?? {};
for (const [key, values] of Object.entries(mapping)) {
if (!values || values.length === 0) {
errors.push(
`Subcategory ${config.name}: '${key}' needs at least 1 subcategory.`,
);
}
}
return {
category: config.subcategory_parent,
values: mapping,
};
}
if (config.sampler_type === "uniform") {
return {
low: parseNumber(config.low),
high: parseNumber(config.high),
};
}
if (config.sampler_type === "gaussian") {
return {
mean: parseNumber(config.mean),
std: parseNumber(config.std),
};
}
if (config.sampler_type === "datetime") {
return {
start: config.datetime_start ?? undefined,
end: config.datetime_end ?? undefined,
unit: config.datetime_unit ?? undefined,
};
}
if (config.sampler_type === "uuid") {
return {
format: config.uuid_format ?? undefined,
};
}
return {
locale: config.person_locale ?? undefined,
sex: config.person_sex ?? undefined,
// biome-ignore lint/style/useNamingConvention: api schema
age_range: config.person_age_range ?? undefined,
city: config.person_city ?? undefined,
// biome-ignore lint/style/useNamingConvention: api schema
with_synthetic_personas: config.person_with_synthetic_personas ?? undefined,
// biome-ignore lint/style/useNamingConvention: api schema
sample_dataset_when_available:
config.person_sample_dataset_when_available ?? undefined,
};
}
function buildLlmColumn(
config: LlmConfig,
errors: string[],
): Record<string, unknown> {
const base = {
name: config.name,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: config.model_alias,
prompt: config.prompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: config.system_prompt || undefined,
};
if (config.llm_type === "code") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-code",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: config.code_lang || "python",
};
}
if (config.llm_type === "structured") {
let outputFormat: unknown = config.output_format || undefined;
if (typeof outputFormat === "string" && outputFormat.trim()) {
try {
outputFormat = JSON.parse(outputFormat);
} catch {
errors.push(`LLM ${config.name}: output_format is not valid JSON.`);
}
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-structured",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: outputFormat,
};
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-text",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: false,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
export function buildCanvasPayload(
configs: Record<string, NodeConfig>,
nodes: CanvasNode[],
edges: Edge[],
): CanvasPayloadResult {
const errors: string[] = [];
const columns: Record<string, unknown>[] = [];
const modelAliases = new Set<string>();
const nameSet = new Set<string>();
const nameToConfig = new Map<string, NodeConfig>();
for (const node of nodes) {
const config = configs[node.id];
if (!config) {
continue;
}
for (const error of getConfigErrors(config)) {
errors.push(`${config.name}: ${error}`);
}
if (nameSet.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
}
nameSet.add(config.name);
if (config.kind === "sampler") {
nameToConfig.set(config.name, config);
columns.push({
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "sampler",
name: config.name,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: config.sampler_type,
params: buildSamplerParams(config, errors),
});
continue;
}
columns.push(buildLlmColumn(config, errors));
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
nameToConfig.set(config.name, config);
}
for (const config of Object.values(configs)) {
if (config.kind !== "sampler" || config.sampler_type !== "subcategory") {
continue;
}
const parentName = config.subcategory_parent;
if (!parentName) {
errors.push(`Subcategory ${config.name}: parent category required.`);
continue;
}
const parent = nameToConfig.get(parentName);
const parentValues =
parent && parent.kind === "sampler" && parent.sampler_type === "category"
? (parent.values ?? [])
: [];
const mapping = config.subcategory_mapping ?? {};
for (const value of parentValues) {
const list = mapping[value];
if (!list || list.length === 0) {
errors.push(
`Subcategory ${config.name}: '${value}' needs at least 1 subcategory.`,
);
}
}
}
const modelProviders = modelAliases.size > 0 ? [DEFAULT_PROVIDER] : [];
const modelConfigs =
modelAliases.size > 0
? Array.from(modelAliases).map((alias) => ({
alias,
...DEFAULT_CONFIG,
}))
: [];
const uiNodes = nodes.flatMap((node) => {
const config = configs[node.id];
if (!config) {
return [];
}
return [
{
id: config.name,
x: node.position.x,
y: node.position.y,
},
];
});
const uiEdges = edges.flatMap((edge) => {
const source = edge.source ? configs[edge.source] : null;
const target = edge.target ? configs[edge.target] : null;
if (!(source && target)) {
return [];
}
return [
{
from: source.name,
to: target.name,
},
];
});
return {
errors,
payload: {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: modelProviders,
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: modelConfigs,
columns,
processors: [],
},
run: {
rows: 5,
preview: true,
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: ["jsonl"],
},
ui: {
nodes: uiNodes,
edges: uiEdges,
},
},
};
}