a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+}
diff --git a/studio/frontend/src/features/data-recipes/data/recipes-db.ts b/studio/frontend/src/features/data-recipes/data/recipes-db.ts
new file mode 100644
index 0000000000..77208bf3d3
--- /dev/null
+++ b/studio/frontend/src/features/data-recipes/data/recipes-db.ts
@@ -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
;
+};
+
+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 {
+ return db.recipes.orderBy("updatedAt").reverse().toArray();
+}
+
+export async function getRecipe(id: string): Promise {
+ return db.recipes.get(id);
+}
+
+export async function saveRecipe(input: SaveRecipeInput): Promise {
+ 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 {
+ await db.recipes.delete(id);
+}
+
+export async function createRecipeDraft(): Promise {
+ return saveRecipe({
+ name: "Unnamed",
+ payload: createEmptyPayload(),
+ });
+}
+
+export function useRecipes(): RecipeRecord[] {
+ const [recipes, setRecipes] = useState([]);
+
+ useEffect(() => {
+ const sub = liveQuery(() => listRecipes()).subscribe({
+ next: (value) => setRecipes(value),
+ error: (error) => console.error("data-recipes liveQuery:", error),
+ });
+ return () => sub.unsubscribe();
+ }, []);
+
+ return recipes;
+}
diff --git a/studio/frontend/src/features/data-recipes/index.ts b/studio/frontend/src/features/data-recipes/index.ts
new file mode 100644
index 0000000000..9e830ab42c
--- /dev/null
+++ b/studio/frontend/src/features/data-recipes/index.ts
@@ -0,0 +1,2 @@
+export { DataRecipesPage } from "./pages/data-recipes-page";
+export { EditRecipeEditorPage } from "./pages/edit-recipe-page";
diff --git a/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx
new file mode 100644
index 0000000000..26e329ddbb
--- /dev/null
+++ b/studio/frontend/src/features/data-recipes/pages/data-recipes-page.tsx
@@ -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 {
+ await deleteRecipe(recipeId);
+ }
+
+ return (
+
+
+
+
+
Data Recipes
+
+ Create and manage local recipe workflows.
+
+
+
+
+
+ {recipes.length === 0 ? (
+
+
+
+
+
+ No recipes yet
+
+ Create your first recipe to start building workflows.
+
+
+
+
+
+
+ ) : (
+
+ {recipes.map((recipe) => (
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx b/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx
new file mode 100644
index 0000000000..6c099e639b
--- /dev/null
+++ b/studio/frontend/src/features/data-recipes/pages/edit-recipe-page.tsx
@@ -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 (
+
+
+
+
{title}
+
{description}
+
+
+
+
+ );
+}
+
+export function EditRecipeEditorPage({ recipeId }: EditRecipePageProps): ReactElement {
+ const navigate = useNavigate();
+ const [loadState, setLoadState] = useState({ 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 (
+ void navigate({ to: "/data-recipes" })}
+ />
+ );
+ }
+
+ if (loadState.status === "missing") {
+ return (
+ void navigate({ to: "/data-recipes" })}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/studio/frontend/src/features/data-recipes/types.ts b/studio/frontend/src/features/data-recipes/types.ts
new file mode 100644
index 0000000000..18b36552ce
--- /dev/null
+++ b/studio/frontend/src/features/data-recipes/types.ts
@@ -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;
+};
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx
index d72346849d..4229236c2d 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-studio-header.tsx
@@ -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 = {
- 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): void {
+ if (event.key === "Enter") {
+ closeWorkflowNameEditor();
+ return;
+ }
+ if (event.key === "Escape") {
+ setEditingWorkflowName(false);
+ }
+ }
+
return (
-
-
-
-
Create Data Recipe
-
- Design synthetic-data pipelines with Data Designer.
-
- {statusMessage && (
-
- {statusMessage.text}
-
+
+
+
+
+ {editingWorkflowName ? (
+ onWorkflowNameChange(event.target.value)}
+ onBlur={closeWorkflowNameEditor}
+ onKeyDown={handleWorkflowNameKeyDown}
+ autoFocus={true}
+ className="h-7 w-[180px]"
+ />
+ ) : (
+
)}
+
+ {STATUS_MESSAGE_CLASS[saveTone]}
+
+ {savedAtLabel}
-
-
-
+
+
+
+
);
diff --git a/studio/frontend/src/features/recipe-studio/index.ts b/studio/frontend/src/features/recipe-studio/index.ts
index 2060d86fa7..fe02998475 100644
--- a/studio/frontend/src/features/recipe-studio/index.ts
+++ b/studio/frontend/src/features/recipe-studio/index.ts
@@ -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";
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index 870c48a5df..332edfbadb 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -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
;
+};
+
+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(null);
+ const [workflowName, setWorkflowName] = useState("Unnamed");
+ const [lastSavedAt, setLastSavedAt] = useState(null);
+ const [savedSignature, setSavedSignature] = useState("");
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 => {
+ 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 => {
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 => {
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 (
-
-
{
+ 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"
- >
-
-
-
-
- setImportOpen(true)}
+ />
+
+
+
-
-
-
+
+
+
+ setImportOpen(true)}
+ />
+
+
+
+
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((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) =>