feat: add support for learning recipes with template loading, dialog integration, and enhanced payload handling

This commit is contained in:
Shine1i 2026-02-23 23:20:32 +01:00
commit 1a7373b99d
7 changed files with 334 additions and 69 deletions

View file

@ -1,6 +1,6 @@
import Dexie, { type EntityTable, liveQuery } from "dexie";
import { createEmptyRecipePayload } from "@/features/recipe-studio";
import { normalizeNonEmptyName } from "@/utils";
import Dexie, { type EntityTable, liveQuery } from "dexie";
import { useEffect, useState } from "react";
import type { RecipeRecord, SaveRecipeInput } from "../types";
@ -12,15 +12,17 @@ db.version(1).stores({
recipes: "id, name, updatedAt, createdAt",
});
export async function listRecipes(): Promise<RecipeRecord[]> {
export function listRecipes(): Promise<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
export async function getRecipe(id: string): Promise<RecipeRecord | undefined> {
export function getRecipe(id: string): Promise<RecipeRecord | undefined> {
return db.recipes.get(id);
}
export async function saveRecipe(input: SaveRecipeInput): Promise<RecipeRecord> {
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;
@ -30,6 +32,9 @@ export async function saveRecipe(input: SaveRecipeInput): Promise<RecipeRecord>
payload: input.payload,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
learningRecipeId: input.learningRecipeId ?? existing?.learningRecipeId,
learningRecipeTitle:
input.learningRecipeTitle ?? existing?.learningRecipeTitle,
};
await db.recipes.put(record);
return record;
@ -39,13 +44,26 @@ export async function deleteRecipe(id: string): Promise<void> {
await db.recipes.delete(id);
}
export async function createRecipeDraft(): Promise<RecipeRecord> {
export function createRecipeDraft(): Promise<RecipeRecord> {
return saveRecipe({
name: "Unnamed",
payload: createEmptyRecipePayload(),
});
}
export function createRecipeFromLearningRecipe(input: {
templateId: string;
templateTitle: string;
payload: RecipeRecord["payload"];
}): Promise<RecipeRecord> {
return saveRecipe({
name: input.templateTitle,
payload: input.payload,
learningRecipeId: input.templateId,
learningRecipeTitle: input.templateTitle,
});
}
export function useRecipes(): RecipeRecord[] {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);

View file

@ -1,16 +1,99 @@
import type { RecipePayload } from "@/features/recipe-studio";
const structuredOutputsJinjaUrl = new URL(
"./structured-outputs-jinja.json",
import.meta.url,
).href;
const pdfGroundedQaUrl = new URL("./pdf-grounded-qa.json", import.meta.url)
.href;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toRecordArray(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is Record<string, unknown> =>
isRecord(item),
);
}
function coerceRecipePayload(value: unknown): RecipePayload {
if (!isRecord(value)) {
throw new Error("Template payload is invalid JSON object.");
}
const recipeSource = isRecord(value.recipe) ? value.recipe : value;
if (!Array.isArray(recipeSource.columns)) {
throw new Error("Template payload must include recipe.columns.");
}
if (isRecord(value.recipe) && isRecord(value.run) && isRecord(value.ui)) {
return value as unknown as RecipePayload;
}
const recipe: RecipePayload["recipe"] = {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: toRecordArray(recipeSource.model_providers),
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: toRecordArray(recipeSource.mcp_providers),
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: toRecordArray(recipeSource.model_configs),
// biome-ignore lint/style/useNamingConvention: api schema
seed_config: isRecord(recipeSource.seed_config)
? recipeSource.seed_config
: undefined,
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: toRecordArray(recipeSource.tool_configs),
columns: toRecordArray(recipeSource.columns),
processors: toRecordArray(recipeSource.processors),
};
return {
recipe,
run: {
rows: 5,
preview: true,
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: ["jsonl"],
},
ui: {
nodes: [],
edges: [],
},
};
}
async function loadPayloadFromUrl(url: string): Promise<RecipePayload> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch template payload (${response.status})`);
}
const json = (await response.json()) as unknown;
return coerceRecipePayload(json);
}
export type LearningRecipeDef = {
id: string;
title: string;
description: string;
filePath: string;
loadPayload: () => Promise<RecipePayload>;
};
export const LEARNING_RECIPES: LearningRecipeDef[] = [
{
id: "structured-outputs-jinja",
title: "Structured Outputs + Jinja Expressions",
description: "Minimal schema + Jinja refs + if/else patterns.",
filePath:
"/src/features/data-recipes/learning-recipes/structured-outputs-jinja.json",
description:
"Support ticket triage with structured JSON outputs and Jinja conditionals.",
loadPayload: () => loadPayloadFromUrl(structuredOutputsJinjaUrl),
},
{
id: "pdf-grounded-qa",
title: "PDF Document QA",
description: "Build grounded question-answer examples from PDF chunks.",
loadPayload: () => loadPayloadFromUrl(pdfGroundedQaUrl),
},
];

View file

@ -1,4 +1,18 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyContent,
@ -8,16 +22,18 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import { ShineBorder } from "@/components/ui/shine-border";
import { toastError } from "@/shared/toast";
import {
AiChat02Icon,
ArrowDown01Icon,
CodeIcon,
CookBookIcon,
Database02Icon,
Delete02Icon,
DocumentAttachmentIcon,
FunctionIcon,
Plant01Icon,
PlusSignIcon,
Shield02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
@ -25,16 +41,20 @@ import type { ReactElement } from "react";
import { useState } from "react";
import {
createRecipeDraft,
createRecipeFromLearningRecipe,
deleteRecipe,
useRecipes,
} from "../data/recipes-db";
import { LEARNING_RECIPES } from "../learning-recipes";
type TemplateCard = {
title: string;
description: string;
icon: typeof CookBookIcon;
difficulty: "Easy" | "Starter" | "Intermediate" | "Advanced";
surfaceClassName: string;
shineColor: string[];
learningRecipeId?: string;
};
const TEMPLATE_CARDS: TemplateCard[] = [
@ -43,24 +63,29 @@ const TEMPLATE_CARDS: TemplateCard[] = [
description:
"Support ticket triage dataset with structured JSON outputs and Jinja if/else refs.",
icon: FunctionIcon,
difficulty: "Advanced",
surfaceClassName:
"from-cyan-500/15 via-sky-500/5 to-transparent border-cyan-500/30",
shineColor: ["#06b6d4", "#38bdf8", "#22d3ee"],
learningRecipeId: "structured-outputs-jinja",
},
{
title: "Basic MCP Tool Use",
title: "PDF Document QA",
description:
"Agent workflow starter showing tool-call patterns and grounded tool result usage.",
icon: Shield02Icon,
"Unstructured PDF chunks transformed into grounded question-answer training pairs.",
icon: DocumentAttachmentIcon,
difficulty: "Easy",
surfaceClassName:
"from-violet-500/15 via-fuchsia-500/5 to-transparent border-violet-500/30",
shineColor: ["#8b5cf6", "#d946ef", "#a855f7"],
learningRecipeId: "pdf-grounded-qa",
},
{
title: "Seed Dataset",
description:
"Start from real rows, then expand with synthetic fields while preserving source context.",
icon: Plant01Icon,
difficulty: "Starter",
surfaceClassName:
"from-emerald-500/15 via-green-500/5 to-transparent border-emerald-500/30",
shineColor: ["#10b981", "#22c55e", "#34d399"],
@ -70,6 +95,7 @@ const TEMPLATE_CARDS: TemplateCard[] = [
description:
"Instruction-to-code pairs for training models that generate clean Python implementations.",
icon: CodeIcon,
difficulty: "Starter",
surfaceClassName:
"from-amber-500/15 via-orange-500/5 to-transparent border-amber-500/30",
shineColor: ["#f59e0b", "#f97316", "#fb923c"],
@ -79,6 +105,7 @@ const TEMPLATE_CARDS: TemplateCard[] = [
description:
"Natural language to SQL pairs, including schema-aware query construction patterns.",
icon: Database02Icon,
difficulty: "Intermediate",
surfaceClassName:
"from-blue-500/15 via-indigo-500/5 to-transparent border-blue-500/30",
shineColor: ["#3b82f6", "#6366f1", "#60a5fa"],
@ -88,12 +115,17 @@ const TEMPLATE_CARDS: TemplateCard[] = [
description:
"Role-based multi-turn conversations for assistant behavior, memory, and response quality.",
icon: AiChat02Icon,
difficulty: "Advanced",
surfaceClassName:
"from-rose-500/15 via-pink-500/5 to-transparent border-rose-500/30",
shineColor: ["#f43f5e", "#ec4899", "#fb7185"],
},
];
const LEARNING_RECIPE_BY_ID = new Map(
LEARNING_RECIPES.map((recipe) => [recipe.id, recipe]),
);
function formatRelativeTime(value: number): string {
const now = Date.now();
const diffMs = Math.max(0, now - value);
@ -121,13 +153,87 @@ function formatRelativeTime(value: number): string {
return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
}
function LearningRecipeCards({
onSelect,
loadingTemplateId,
}: {
onSelect: (template: TemplateCard) => void;
loadingTemplateId: string | null;
}): ReactElement {
return (
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
{TEMPLATE_CARDS.map((template) => {
const learningRecipe = template.learningRecipeId
? LEARNING_RECIPE_BY_ID.get(template.learningRecipeId)
: undefined;
const isReady = Boolean(learningRecipe);
const isLoading =
template.learningRecipeId !== undefined &&
loadingTemplateId === template.learningRecipeId;
const isDisabled = !isReady || isLoading || Boolean(loadingTemplateId);
return (
<button
key={template.title}
type="button"
disabled={isDisabled}
onClick={() => onSelect(template)}
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br text-left transition-transform ${template.surfaceClassName} enabled:cursor-pointer enabled:hover:-translate-y-0.5 enabled:hover:shadow-md disabled:cursor-not-allowed disabled:opacity-70`}
>
<ShineBorder
borderWidth={1.2}
duration={11}
shineColor={template.shineColor}
/>
<div className="relative flex h-full min-h-40 flex-col justify-between gap-3 p-4">
<div className="inline-flex size-10 items-center justify-center rounded-xl border border-foreground/10 bg-background/80">
<HugeiconsIcon
icon={template.icon}
className="size-5 text-foreground/90"
/>
</div>
<div className="space-y-1">
<p className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
{template.title}
</p>
<p className="line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant={
template.difficulty === "Advanced" ? "secondary" : "outline"
}
>
{template.difficulty}
</Badge>
{isLoading ? (
<Badge variant="outline">Loading...</Badge>
) : (
<Badge variant={isReady ? "outline" : "secondary"}>
{isReady ? "Learning Recipe" : "Soon"}
</Badge>
)}
</div>
</div>
</button>
);
})}
</div>
);
}
export function DataRecipesPage(): ReactElement {
const navigate = useNavigate();
const recipes = useRecipes();
const [creatingRecipe, setCreatingRecipe] = useState(false);
const [learningDialogOpen, setLearningDialogOpen] = useState(false);
const [loadingTemplateId, setLoadingTemplateId] = useState<string | null>(
null,
);
async function openNewRecipe(): Promise<void> {
if (creatingRecipe) {
if (creatingRecipe || loadingTemplateId) {
return;
}
setCreatingRecipe(true);
@ -142,6 +248,43 @@ export function DataRecipesPage(): ReactElement {
}
}
async function openLearningRecipe(template: TemplateCard): Promise<void> {
if (creatingRecipe || loadingTemplateId) {
return;
}
if (!template.learningRecipeId) {
toastError("Learning recipe not ready yet.");
return;
}
const recipeTemplate = LEARNING_RECIPE_BY_ID.get(template.learningRecipeId);
if (!recipeTemplate) {
toastError("Learning recipe not found.");
return;
}
setLoadingTemplateId(template.learningRecipeId);
try {
const payload = await recipeTemplate.loadPayload();
const recipe = await createRecipeFromLearningRecipe({
templateId: recipeTemplate.id,
templateTitle: recipeTemplate.title,
payload,
});
setLearningDialogOpen(false);
await navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
} catch (error) {
toastError(
"Failed to start learning recipe.",
error instanceof Error ? error.message : undefined,
);
} finally {
setLoadingTemplateId(null);
}
}
function openRecipe(recipeId: string): void {
navigate({
to: "/data-recipes/$recipeId",
@ -153,6 +296,8 @@ export function DataRecipesPage(): ReactElement {
await deleteRecipe(recipeId);
}
const isBusy = creatingRecipe || Boolean(loadingTemplateId);
return (
<div className="min-h-screen bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
@ -165,16 +310,33 @@ export function DataRecipesPage(): ReactElement {
Create and manage local recipe workflows.
</p>
</div>
<Button
type="button"
onClick={() => {
openNewRecipe().catch(() => undefined);
}}
disabled={creatingRecipe}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<Button type="button" disabled={isBusy}>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
<HugeiconsIcon icon={ArrowDown01Icon} className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
openNewRecipe().catch(() => undefined);
}}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
Start Empty
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setLearningDialogOpen(true);
}}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
Start from Learning Recipe
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{recipes.length === 0 ? (
@ -194,44 +356,18 @@ export function DataRecipesPage(): ReactElement {
type="button"
variant="secondary"
className="mx-auto"
disabled={true}
onClick={() => setLearningDialogOpen(true)}
disabled={isBusy}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
Start Tutorial
</Button>
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
{TEMPLATE_CARDS.map((template) => (
<div
key={template.title}
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br ${template.surfaceClassName}`}
>
<ShineBorder
borderWidth={1.2}
duration={11}
shineColor={template.shineColor}
/>
<div className="relative flex h-full min-h-40 flex-col justify-between gap-3 p-4 text-left">
<div className="inline-flex size-10 items-center justify-center rounded-xl border border-foreground/10 bg-background/80">
<HugeiconsIcon
icon={template.icon}
className="size-5 text-foreground/90"
/>
</div>
<div className="space-y-1">
<p className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
{template.title}
</p>
<p className="line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
</div>
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
Template
</span>
</div>
</div>
))}
</div>
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</EmptyContent>
</Empty>
) : (
@ -253,9 +389,14 @@ export function DataRecipesPage(): ReactElement {
/>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{recipe.name}
</p>
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{recipe.name}
</p>
{recipe.learningRecipeId ? (
<Badge variant="outline">Learning Recipe</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Last updated {formatRelativeTime(recipe.updatedAt)} |
Created {formatRelativeTime(recipe.createdAt)}
@ -279,6 +420,23 @@ export function DataRecipesPage(): ReactElement {
</div>
)}
</main>
<Dialog open={learningDialogOpen} onOpenChange={setLearningDialogOpen}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Learning Recipes</DialogTitle>
<DialogDescription>
Start from a prebuilt recipe to learn patterns, then edit and run.
</DialogDescription>
</DialogHeader>
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -6,10 +6,14 @@ export type RecipeRecord = {
payload: RecipePayload;
createdAt: number;
updatedAt: number;
learningRecipeId?: string;
learningRecipeTitle?: string;
};
export type SaveRecipeInput = {
id?: string | null;
name: string;
payload: RecipePayload;
learningRecipeId?: string;
learningRecipeTitle?: string;
};

View file

@ -31,7 +31,9 @@ export function buildEdges(
seen.add(key);
const source = configByName.get(from);
const target = configByName.get(to);
const isSemantic = Boolean(source && target && isSemanticConnection(source, target));
const isSemantic = Boolean(
source && target && isSemanticConnection(source, target),
);
const normalizedType = isSemantic ? "semantic" : "canvas";
const handles =
normalizedType === "semantic"
@ -56,7 +58,9 @@ export function buildEdges(
for (const edge of uiEdges) {
addEdgeByName(edge.from, edge.to);
}
return edges;
if (edges.length > 0) {
return edges;
}
}
for (const config of configs) {

View file

@ -44,8 +44,6 @@ export function buildProcessors(
// biome-ignore lint/style/useNamingConvention: api schema
processor_type: "schema_transform",
name,
// biome-ignore lint/style/useNamingConvention: api schema
build_stage: "post_batch",
template,
});
}

View file

@ -85,7 +85,9 @@ export function buildSeedDropProcessor(
}
const cols = (config.seed_columns ?? []).map((c) => c.trim()).filter(Boolean);
if (cols.length === 0) {
errors.push(`Seed ${config.name}: drop enabled but no seed columns loaded.`);
errors.push(
`Seed ${config.name}: drop enabled but no seed columns loaded.`,
);
return null;
}
return {
@ -93,8 +95,6 @@ export function buildSeedDropProcessor(
processor_type: "drop_columns",
name: "drop_seed_columns",
// biome-ignore lint/style/useNamingConvention: api schema
build_stage: "post_batch",
// biome-ignore lint/style/useNamingConvention: api schema
column_names: cols,
};
}