feat: add support for learning recipes with template loading, dialog integration, and enhanced payload handling
This commit is contained in:
parent
813e2b5bb4
commit
1a7373b99d
7 changed files with 334 additions and 69 deletions
|
|
@ -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[]>([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue