add canvaslab
This commit is contained in:
parent
a295a624ce
commit
931891b207
25 changed files with 4973 additions and 2319 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
15
studio/frontend/src/app/routes/canvas-lab.tsx
Normal file
15
studio/frontend/src/app/routes/canvas-lab.tsx
Normal 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,
|
||||
});
|
||||
28
studio/frontend/src/features/canvas-lab/api/index.ts
Normal file
28
studio/frontend/src/features/canvas-lab/api/index.ts
Normal 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();
|
||||
}
|
||||
200
studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx
Normal file
200
studio/frontend/src/features/canvas-lab/canvas-lab-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
1
studio/frontend/src/features/canvas-lab/index.ts
Normal file
1
studio/frontend/src/features/canvas-lab/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CanvasLabPage } from "./canvas-lab-page";
|
||||
343
studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts
Normal file
343
studio/frontend/src/features/canvas-lab/stores/canvas-lab.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
78
studio/frontend/src/features/canvas-lab/types/index.ts
Normal file
78
studio/frontend/src/features/canvas-lab/types/index.ts
Normal 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;
|
||||
312
studio/frontend/src/features/canvas-lab/utils/index.ts
Normal file
312
studio/frontend/src/features/canvas-lab/utils/index.ts
Normal 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;
|
||||
}
|
||||
315
studio/frontend/src/features/canvas-lab/utils/payload.ts
Normal file
315
studio/frontend/src/features/canvas-lab/utils/payload.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue