feat: implement Data Recipes page feature subfolders for workflow management and saving logic

This commit is contained in:
Shine1i 2026-02-12 02:07:53 +01:00
commit 30cc509197
15 changed files with 838 additions and 146 deletions

View file

@ -1,6 +1,7 @@
import { createRouter } from "@tanstack/react-router";
import { Route as rootRoute } from "./routes/__root";
import { Route as dataRecipesNewRoute } from "./routes/data-recipes-new";
import { Route as dataRecipesRoute } from "./routes/data-recipes";
import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId";
import { Route as chatRoute } from "./routes/chat";
import { Route as exportRoute } from "./routes/export";
import { Route as gridTestRoute } from "./routes/grid-test";
@ -19,7 +20,8 @@ const routeTree = rootRoute.addChildren([
studioRoute,
chatRoute,
exportRoute,
dataRecipesNewRoute,
dataRecipesRoute,
dataRecipeRoute,
]);
export const router = createRouter({ routeTree });

View file

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

View file

@ -0,0 +1,23 @@
import { createRoute } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const EditRecipeEditorPage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.EditRecipeEditorPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/data-recipes/$recipeId",
beforeLoad: () => requireAuth(),
component: DataRecipeEditorRoute,
});
function DataRecipeEditorRoute(): ReactElement {
const { recipeId } = Route.useParams();
return <EditRecipeEditorPage recipeId={recipeId} />;
}

View file

@ -0,0 +1,17 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const DataRecipesPage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.DataRecipesPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/data-recipes",
beforeLoad: () => requireAuth(),
component: DataRecipesPage,
});

View file

@ -9,6 +9,7 @@ import {
Analytics01Icon,
ArrowRight01Icon,
Book03Icon,
CookBookIcon,
PackageIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
@ -19,6 +20,7 @@ import { useState } from "react";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
{ label: "Recipes", href: "/data-recipes", icon: CookBookIcon, enabled: true },
{ label: "Evaluate", href: "/evaluate", icon: Analytics01Icon, enabled: false },
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
{ label: "Chat", href: "/chat", icon: AiChat02Icon, enabled: true },
@ -65,7 +67,8 @@ export function Navbar() {
{/* Center: pill nav */}
<nav className="flex items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
if (!item.enabled) {
return (
<span

View file

@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"gap-4 rounded-lg border-dashed p-12 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"gap-2 flex max-w-sm flex-col items-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"gap-4 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View file

@ -0,0 +1,88 @@
import Dexie, { type EntityTable, liveQuery } from "dexie";
import type { RecipePayload } from "@/features/recipe-studio";
import { useEffect, useState } from "react";
import type { RecipeRecord, SaveRecipeInput } from "../types";
const db = new Dexie("unsloth-data-recipes") as Dexie & {
recipes: EntityTable<RecipeRecord, "id">;
};
db.version(1).stores({
recipes: "id, name, updatedAt, createdAt",
});
function normalizeRecipeName(name: string): string {
const trimmed = name.trim();
return trimmed.length > 0 ? trimmed : "Unnamed";
}
function createEmptyPayload(): RecipePayload {
return {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: [],
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: [],
columns: [],
processors: [],
},
run: {
rows: 5,
preview: true,
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: ["jsonl"],
},
ui: {
nodes: [],
edges: [],
},
};
}
export async function listRecipes(): Promise<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
export async function getRecipe(id: string): Promise<RecipeRecord | undefined> {
return db.recipes.get(id);
}
export async function saveRecipe(input: SaveRecipeInput): Promise<RecipeRecord> {
const now = Date.now();
const id = input.id ?? crypto.randomUUID();
const existing = input.id ? await db.recipes.get(input.id) : undefined;
const record: RecipeRecord = {
id,
name: normalizeRecipeName(input.name),
payload: input.payload,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await db.recipes.put(record);
return record;
}
export async function deleteRecipe(id: string): Promise<void> {
await db.recipes.delete(id);
}
export async function createRecipeDraft(): Promise<RecipeRecord> {
return saveRecipe({
name: "Unnamed",
payload: createEmptyPayload(),
});
}
export function useRecipes(): RecipeRecord[] {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
useEffect(() => {
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => setRecipes(value),
error: (error) => console.error("data-recipes liveQuery:", error),
});
return () => sub.unsubscribe();
}, []);
return recipes;
}

View file

@ -0,0 +1,2 @@
export { DataRecipesPage } from "./pages/data-recipes-page";
export { EditRecipeEditorPage } from "./pages/edit-recipe-page";

View file

@ -0,0 +1,151 @@
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { CookBookIcon, Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useState } from "react";
import { createRecipeDraft, deleteRecipe, useRecipes } from "../data/recipes-db";
function formatRelativeTime(value: number): string {
const now = Date.now();
const diffMs = Math.max(0, now - value);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
if (diffMs < minute) {
return "just now";
}
if (diffMs < hour) {
const minutes = Math.floor(diffMs / minute);
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
}
if (diffMs < day) {
const hours = Math.floor(diffMs / hour);
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
if (diffMs < week) {
const days = Math.floor(diffMs / day);
return `${days} day${days === 1 ? "" : "s"} ago`;
}
const weeks = Math.floor(diffMs / week);
return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
}
export function DataRecipesPage(): ReactElement {
const navigate = useNavigate();
const recipes = useRecipes();
const [creatingRecipe, setCreatingRecipe] = useState(false);
function openNewRecipe(): void {
if (creatingRecipe) {
return;
}
setCreatingRecipe(true);
void createRecipeDraft()
.then((recipe) => {
void navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
})
.finally(() => {
setCreatingRecipe(false);
});
}
function openRecipe(recipeId: string): void {
void navigate({
to: "/data-recipes/$recipeId",
params: { recipeId },
});
}
async function handleDeleteRecipe(recipeId: string): Promise<void> {
await deleteRecipe(recipeId);
}
return (
<div className="min-h-screen bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Data Recipes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Create and manage local recipe workflows.
</p>
</div>
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
</Button>
</div>
{recipes.length === 0 ? (
<Empty className="mt-8 border border-dashed border-border/70">
<EmptyHeader>
<EmptyMedia variant="icon">
<HugeiconsIcon icon={CookBookIcon} className="size-5" />
</EmptyMedia>
<EmptyTitle>No recipes yet</EmptyTitle>
<EmptyDescription>
Create your first recipe to start building workflows.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={openNewRecipe} disabled={creatingRecipe}>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
Create Recipe
</Button>
</EmptyContent>
</Empty>
) : (
<div className="mt-8 space-y-2">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => openRecipe(recipe.id)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{recipe.name}</p>
<p className="text-xs text-muted-foreground">
Last updated {formatRelativeTime(recipe.updatedAt)} | Created{" "}
{formatRelativeTime(recipe.createdAt)}
</p>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => void handleDeleteRecipe(recipe.id)}
aria-label={`Delete ${recipe.name}`}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { Button } from "@/components/ui/button";
import { RecipeStudioPage, type RecipePayload } from "@/features/recipe-studio";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useCallback, useEffect, useState } from "react";
import { getRecipe, saveRecipe } from "../data/recipes-db";
import type { RecipeRecord } from "../types";
type EditRecipePageProps = {
recipeId: string;
};
type LoadState =
| { status: "loading" }
| { status: "missing" }
| { status: "ready"; record: RecipeRecord };
function RecipeLoadState({
title,
description,
onBack,
}: {
title: string;
description: string;
onBack: () => void;
}): ReactElement {
return (
<div className="min-h-screen bg-background">
<main className="mx-auto flex min-h-[70vh] w-full max-w-4xl items-center justify-center px-6 py-8">
<div className="w-full rounded-2xl border bg-card p-8 text-center">
<h1 className="text-lg font-semibold">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<Button type="button" variant="outline" className="mt-5" onClick={onBack}>
Back to Recipes
</Button>
</div>
</main>
</div>
);
}
export function EditRecipeEditorPage({ recipeId }: EditRecipePageProps): ReactElement {
const navigate = useNavigate();
const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
useEffect(() => {
let active = true;
void getRecipe(recipeId).then((record) => {
if (!active) {
return;
}
if (!record) {
setLoadState({ status: "missing" });
return;
}
setLoadState({ status: "ready", record });
});
return () => {
active = false;
};
}, [recipeId]);
const handlePersist = useCallback(
async (input: { id: string | null; name: string; payload: RecipePayload }) => {
const record = await saveRecipe({
id: input.id ?? recipeId,
name: input.name,
payload: input.payload,
});
return { id: record.id, updatedAt: record.updatedAt };
},
[recipeId],
);
if (loadState.status === "loading") {
return (
<RecipeLoadState
title="Loading recipe..."
description="Please wait while we load your recipe."
onBack={() => void navigate({ to: "/data-recipes" })}
/>
);
}
if (loadState.status === "missing") {
return (
<RecipeLoadState
title="Recipe not found"
description="This recipe may have been deleted."
onBack={() => void navigate({ to: "/data-recipes" })}
/>
);
}
return (
<RecipeStudioPage
key={loadState.record.id}
recipeId={loadState.record.id}
initialRecipeName={loadState.record.name}
initialPayload={loadState.record.payload}
initialSavedAt={loadState.record.updatedAt}
onPersistRecipe={handlePersist}
/>
);
}

View file

@ -0,0 +1,15 @@
import type { RecipePayload } from "@/features/recipe-studio";
export type RecipeRecord = {
id: string;
name: string;
payload: RecipePayload;
createdAt: number;
updatedAt: number;
};
export type SaveRecipeInput = {
id?: string | null;
name: string;
payload: RecipePayload;
};

View file

@ -1,60 +1,111 @@
import type { ReactElement } from "react";
import { EyeIcon } from "@hugeicons/core-free-icons";
import { type KeyboardEvent, type ReactElement, useState } from "react";
import {
CookBookIcon,
FloppyDiskIcon,
TestTubeIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Input } from "@/components/ui/input";
type StatusTone = "success" | "error";
type RecipeStudioHeaderProps = {
previewLoading: boolean;
statusMessage: {
tone: StatusTone;
text: string;
} | null;
saveLoading: boolean;
saveTone: StatusTone;
savedAtLabel: string;
workflowName: string;
onWorkflowNameChange: (value: string) => void;
onPreview: () => void;
onSaveRecipe: () => void;
};
const STATUS_MESSAGE_CLASS: Record<StatusTone, string> = {
success: "mt-2 text-xs text-emerald-600",
error: "mt-2 text-xs text-rose-600",
success: "Saved",
error: "Unsaved changes",
};
export function RecipeStudioHeader({
previewLoading,
statusMessage,
saveLoading,
saveTone,
savedAtLabel,
workflowName,
onWorkflowNameChange,
onPreview,
onSaveRecipe,
}: RecipeStudioHeaderProps): ReactElement {
const [editingWorkflowName, setEditingWorkflowName] = useState(false);
function closeWorkflowNameEditor(): void {
if (workflowName.trim().length === 0) {
onWorkflowNameChange("Unnamed");
}
setEditingWorkflowName(false);
}
function handleWorkflowNameKeyDown(event: KeyboardEvent<HTMLInputElement>): void {
if (event.key === "Enter") {
closeWorkflowNameEditor();
return;
}
if (event.key === "Escape") {
setEditingWorkflowName(false);
}
}
return (
<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">Create Data Recipe</h1>
<p className="text-sm text-muted-foreground">
Design synthetic-data pipelines with Data Designer.
</p>
{statusMessage && (
<p className={STATUS_MESSAGE_CLASS[statusMessage.tone]}>
{statusMessage.text}
</p>
<div className="flex items-center justify-between gap-4 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className="flex size-8 items-center justify-center rounded-lg corner-squircle border border-border/70 bg-muted/20"
aria-label="Recipe icon"
>
<HugeiconsIcon icon={CookBookIcon} className="size-4 text-muted-foreground" />
</button>
<div className="flex min-w-0 items-center gap-2">
{editingWorkflowName ? (
<Input
value={workflowName}
onChange={(event) => onWorkflowNameChange(event.target.value)}
onBlur={closeWorkflowNameEditor}
onKeyDown={handleWorkflowNameKeyDown}
autoFocus={true}
className="h-7 w-[180px]"
/>
) : (
<button
type="button"
onClick={() => setEditingWorkflowName(true)}
className="truncate text-sm font-semibold text-foreground hover:text-primary"
>
{workflowName}
</button>
)}
<Badge variant="secondary" className="h-6 text-[10px]">
{STATUS_MESSAGE_CLASS[saveTone]}
</Badge>
<span className="text-xs text-muted-foreground">{savedAtLabel}</span>
</div>
<div className="flex items-center justify-start gap-2 lg:justify-end">
<Button
type="button"
size="sm"
onClick={onPreview}
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 className="flex items-center gap-2">
<Button type="button" size="sm" onClick={onPreview} disabled={previewLoading}>
<HugeiconsIcon icon={TestTubeIcon} className="size-3.5" />
{previewLoading ? "Previewing..." : "Preview"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onSaveRecipe}
disabled={saveLoading}
>
<HugeiconsIcon icon={FloppyDiskIcon} className="size-3.5" />
{saveLoading ? "Saving..." : "Save Recipe"}
</Button>
</div>
</div>
);

View file

@ -1 +1,7 @@
export { RecipeStudioPage } from "./recipe-studio-page";
export type {
PersistRecipeInput,
PersistRecipeResult,
RecipeStudioPageProps,
} from "./recipe-studio-page";
export type { RecipePayload } from "./utils/payload/types";

View file

@ -42,18 +42,60 @@ import { isCategoryConfig } from "./utils";
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
import { importRecipePayload } from "./utils/import";
import { buildRecipePayload } from "./utils/payload";
import type { RecipePayload } from "./utils/payload/types";
import { buildDefaultSchemaTransform } from "./utils/processors";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
type StatusTone = "success" | "error";
type StatusMessage = {
tone: StatusTone;
text: string;
export type PersistRecipeInput = {
id: string | null;
name: string;
payload: RecipePayload;
};
export function RecipeStudioPage(): ReactElement {
export type PersistRecipeResult = {
id: string;
updatedAt: number;
};
export type RecipeStudioPageProps = {
recipeId: string;
initialRecipeName: string;
initialPayload: RecipePayload;
initialSavedAt: number;
onPersistRecipe: (input: PersistRecipeInput) => Promise<PersistRecipeResult>;
};
function buildSignature(name: string, payload: RecipePayload): string {
return JSON.stringify({ name, payload });
}
function normalizeWorkflowName(value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : "Unnamed";
}
function formatSavedLabel(savedAt: number | null): string {
if (!savedAt) {
return "Not saved yet";
}
const time = new Date(savedAt).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
return `Saved ${time}`;
}
export function RecipeStudioPage({
recipeId,
initialRecipeName,
initialPayload,
initialSavedAt,
onPersistRecipe,
}: RecipeStudioPageProps): ReactElement {
const {
nodes,
edges,
@ -79,6 +121,7 @@ export function RecipeStudioPage(): ReactElement {
setSheetView,
setProcessors,
setDialogOpen,
resetRecipe,
loadRecipe,
setLayoutDirection,
applyLayout,
@ -112,6 +155,7 @@ export function RecipeStudioPage(): ReactElement {
setSheetView: state.setSheetView,
setProcessors: state.setProcessors,
setDialogOpen: state.setDialogOpen,
resetRecipe: state.resetRecipe,
loadRecipe: state.loadRecipe,
setLayoutDirection: state.setLayoutDirection,
applyLayout: state.applyLayout,
@ -125,11 +169,14 @@ export function RecipeStudioPage(): ReactElement {
null,
);
const [previewLoading, setPreviewLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [processorsOpen, setProcessorsOpen] = useState(false);
const [interactive, setInteractive] = useState(true);
const [statusMessage, setStatusMessage] = useState<StatusMessage | null>(null);
const [workflowName, setWorkflowName] = useState("Unnamed");
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
const [savedSignature, setSavedSignature] = useState<string>("");
const baseNodeIds = useMemo(
() => new Set(nodes.map((node) => node.id)),
@ -249,45 +296,116 @@ export function RecipeStudioPage(): ReactElement {
setInteractive((value) => !value);
}, []);
const setSuccessStatus = useCallback((text: string) => {
setStatusMessage({ tone: "success", text });
}, []);
const setErrorStatus = useCallback((text: string) => {
setStatusMessage({ tone: "error", text });
}, []);
const buildPayload = useCallback(
const payloadResult = useMemo(
() => buildRecipePayload(configs, nodes, edges, processors),
[configs, nodes, edges, processors],
[configs, edges, nodes, processors],
);
const currentPayload = payloadResult.payload;
const normalizedWorkflowName = useMemo(
() => normalizeWorkflowName(workflowName),
[workflowName],
);
const currentSignature = useMemo(
() => buildSignature(normalizedWorkflowName, currentPayload),
[currentPayload, normalizedWorkflowName],
);
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
const saveTone: StatusTone =
!isDirty && Boolean(lastSavedAt) ? "success" : "error";
const savedAtLabel = formatSavedLabel(lastSavedAt);
useEffect(() => {
const nextName = normalizeWorkflowName(initialRecipeName);
resetRecipe();
setWorkflowName(nextName);
setLastSavedAt(initialSavedAt);
setCopied(false);
const parsed = importRecipePayload(JSON.stringify(initialPayload));
if (parsed.snapshot) {
loadRecipe(parsed.snapshot);
} else {
console.error("Failed to load recipe payload.", parsed.errors);
}
const state = useRecipeStudioStore.getState();
const { payload } = buildRecipePayload(
state.configs,
state.nodes,
state.edges,
state.processors,
);
setSavedSignature(buildSignature(nextName, payload));
}, [
initialPayload,
initialRecipeName,
initialSavedAt,
loadRecipe,
recipeId,
resetRecipe,
]);
const persistRecipe = useCallback(async (): Promise<void> => {
if (saveLoading) {
return;
}
const nextName = normalizeWorkflowName(workflowName);
if (nextName !== workflowName) {
setWorkflowName(nextName);
}
setSaveLoading(true);
try {
const result = await onPersistRecipe({
id: recipeId,
name: nextName,
payload: currentPayload,
});
setLastSavedAt(result.updatedAt);
setSavedSignature(buildSignature(nextName, currentPayload));
} catch (error) {
console.error("Save recipe failed:", error);
} finally {
setSaveLoading(false);
}
}, [
currentPayload,
onPersistRecipe,
recipeId,
saveLoading,
workflowName,
]);
useEffect(() => {
if (!isDirty || saveLoading) {
return;
}
const timeoutId = window.setTimeout(() => {
void persistRecipe();
}, 800);
return () => window.clearTimeout(timeoutId);
}, [isDirty, persistRecipe, saveLoading]);
const readPayload = useCallback(
(fallbackError: string) => {
const { payload, errors } = buildPayload();
if (errors.length === 0) {
return payload;
() => {
if (payloadResult.errors.length === 0) {
return payloadResult.payload;
}
setErrorStatus(errors[0] ?? fallbackError);
return null;
},
[buildPayload, setErrorStatus],
[payloadResult.errors.length, payloadResult.payload],
);
const handlePreview = async (): Promise<void> => {
setPreviewLoading(true);
setStatusMessage(null);
const payload = readPayload("Fix config errors before preview.");
const payload = readPayload();
if (!payload) {
setPreviewLoading(false);
return;
}
try {
const result = await previewRecipe(payload);
const rows = Array.isArray(result.dataset) ? result.dataset.length : 0;
setSuccessStatus(`Preview ready (${rows} rows).`);
await previewRecipe(payload);
} catch (error) {
setErrorStatus(error instanceof Error ? error.message : "Preview failed.");
console.error("Preview failed:", error);
} finally {
setPreviewLoading(false);
}
@ -295,22 +413,20 @@ export function RecipeStudioPage(): ReactElement {
const handleCopyRecipe = async (): Promise<void> => {
setCopied(false);
setStatusMessage(null);
const payload = readPayload("Fix config errors before copy.");
const payload = readPayload();
if (!payload) {
return;
}
if (!navigator.clipboard) {
setErrorStatus("Clipboard not available.");
console.error("Clipboard not available.");
return;
}
try {
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
setSuccessStatus("Recipe copied to clipboard.");
} catch (error) {
setErrorStatus(error instanceof Error ? error.message : "Copy failed.");
console.error("Copy failed:", error);
}
};
@ -320,7 +436,6 @@ export function RecipeStudioPage(): ReactElement {
return result.errors[0] ?? "Invalid payload.";
}
loadRecipe(result.snapshot);
setSuccessStatus("Recipe imported.");
return null;
};
@ -338,69 +453,78 @@ export function RecipeStudioPage(): ReactElement {
return (
<div className="min-h-screen bg-background">
<main className="w-full px-6 py-8">
<RecipeStudioHeader
previewLoading={previewLoading}
statusMessage={statusMessage}
onPreview={handlePreview}
/>
<div
className="relative h-[75vh] w-full rounded-2xl corner-squircle border "
className="relative w-full overflow-hidden rounded-2xl corner-squircle border"
ref={setSheetContainer}
>
<ReactFlow
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
<RecipeStudioHeader
previewLoading={previewLoading}
saveLoading={saveLoading}
saveTone={saveTone}
savedAtLabel={savedAtLabel}
workflowName={workflowName}
onWorkflowNameChange={setWorkflowName}
onPreview={handlePreview}
onSaveRecipe={() => {
void persistRecipe();
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}
fitView={true}
className="h-full w-full"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={handleCopyRecipe}
onImport={() => setImportOpen(true)}
/>
<div className="h-[75vh] w-full rounded-t-none">
<ReactFlow
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
defaultEdgeOptions={{
type: "canvas",
data: { key: "name", path: "auto" },
style: { strokeWidth: 1.5, stroke: "var(--border)" },
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}
fitView={true}
className="h-full w-full rounded-t-none"
>
<LayoutControls
direction={layoutDirection}
onLayout={applyLayout}
onToggleDirection={handleToggleDirection}
/>
</Panel>
<ViewportControls
interactive={interactive}
onToggleInteractive={toggleInteractive}
/>
</ReactFlow>
<InternalsSync nodeIds={displayNodeIds} />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
<Panel position="top-right" className="m-3">
<BlockSheet
container={sheetContainer}
sheetView={sheetView}
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={handleCopyRecipe}
onImport={() => setImportOpen(true)}
/>
</Panel>
<ViewportControls
interactive={interactive}
onToggleInteractive={toggleInteractive}
/>
</ReactFlow>
</div>
</div>
</main>
<ConfigDialog

View file

@ -55,6 +55,7 @@ type RecipeStudioState = {
setSheetView: (view: SheetView) => void;
setProcessors: (processors: RecipeProcessorConfig[]) => void;
setDialogOpen: (open: boolean) => void;
resetRecipe: () => void;
selectConfig: (id: string) => void;
openConfig: (id: string) => void;
setLayoutDirection: (direction: LayoutDirection) => void;
@ -113,6 +114,21 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
setSheetView: (view) => set({ sheetView: view }),
setProcessors: (processors) => set({ processors }),
setDialogOpen: (open) => set({ dialogOpen: open }),
resetRecipe: () =>
set({
nodes: [],
edges: [],
auxNodePositions: {},
auxNodeSizes: {},
configs: {},
processors: [],
sheetView: "root",
activeConfigId: null,
dialogOpen: false,
layoutDirection: "LR",
nextId: 3,
nextY: 280,
}),
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
setLayoutDirection: (direction) =>