feat: regenerate recipe with modifications from the editor

Every existing AI entry point (generate, generate-from-idea, adapt,
variations) either creates a new recipe or a saved variation — none
let you revise the draft you're currently editing in place. Adds a
stateless /api/v1/ai/regenerate endpoint that takes the editor's
current in-progress fields (not a recipeId, so unsaved edits are
included) plus a free-text instruction, and returns a full revised
draft the editor merges into its own state. No DB write happens;
the user still saves normally.

v0.42.0
This commit is contained in:
Arnaud
2026-07-17 17:11:37 +02:00
parent 77c739960d
commit 25e624f618
11 changed files with 314 additions and 3 deletions
@@ -24,6 +24,8 @@ import {
} from "@/components/ui/alert-dialog";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
type DietaryTags = {
vegan?: boolean;
@@ -217,6 +219,31 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
// Merges an AI-regenerated draft into the in-progress form state — it
// never touches the DB directly, so the user still has to hit Save.
function applyRegenerated(recipe: RegeneratedRecipe) {
setTitle(recipe.title);
if (recipe.description !== undefined) setDescription(recipe.description);
setBaseServings(String(recipe.baseServings));
if (recipe.difficulty) setDifficulty(recipe.difficulty);
setPrepMins(recipe.prepMins !== undefined ? String(recipe.prepMins) : "");
setCookMins(recipe.cookMins !== undefined ? String(recipe.cookMins) : "");
if (recipe.dietaryTags) setDietaryTags(recipe.dietaryTags);
setIngredients(recipe.ingredients.map((ing) => ({
id: crypto.randomUUID(),
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : "",
unit: ing.unit ?? "",
note: ing.note ?? "",
})));
setSteps(recipe.steps.map((step) => ({
id: crypto.randomUUID(),
instruction: step.instruction,
timerSeconds: step.timerSeconds !== undefined ? String(step.timerSeconds) : "",
appliesTo: [],
})));
}
function addTag(raw: string) {
const tag = raw.trim().toLowerCase().slice(0, 50);
if (!tag || tags.includes(tag) || tags.length >= 20) return;
@@ -392,6 +419,21 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl pb-20">
{/* Basic info */}
<div className="space-y-4">
{isEdit && (
<div className="flex justify-end">
<RegenerateRecipeButton
current={{
title,
description,
baseServings: Number(baseServings) || 1,
difficulty,
ingredients: ingredients.map((ing) => ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit })),
steps: steps.map((step) => ({ instruction: step.instruction })),
}}
onRegenerated={applyRegenerated}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="title">{t("titleLabel")}</Label>
<Input
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Wand2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
type CurrentRecipe = {
title: string;
description?: string;
baseServings: number;
difficulty?: "easy" | "medium" | "hard" | "";
ingredients: Array<{ rawName: string; quantity?: string; unit?: string }>;
steps: Array<{ instruction: string }>;
};
export function RegenerateRecipeButton({
current,
onRegenerated,
}: {
current: CurrentRecipe;
onRegenerated: (recipe: RegeneratedRecipe) => void;
}) {
const t = useTranslations("recipe");
const [open, setOpen] = useState(false);
const [instruction, setInstruction] = useState("");
const [regenerating, setRegenerating] = useState(false);
async function handleRegenerate() {
if (!instruction.trim()) return;
setRegenerating(true);
try {
const res = await fetch("/api/v1/ai/regenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: current.title,
description: current.description || undefined,
baseServings: current.baseServings,
difficulty: current.difficulty || undefined,
ingredients: current.ingredients,
steps: current.steps,
instruction: instruction.trim(),
}),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? t("regenerateFailed"));
return;
}
const regenerated = await res.json() as RegeneratedRecipe;
onRegenerated(regenerated);
toast.success(t("regenerated"));
setOpen(false);
setInstruction("");
} catch {
toast.error(t("regenerateFailed"));
} finally {
setRegenerating(false);
}
}
return (
<>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)} className="gap-1.5">
<Wand2 className="h-3.5 w-3.5" />
{t("regenerateWithAi")}
</Button>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) setInstruction(""); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
{t("regenerateTitle")}
</DialogTitle>
<DialogDescription>{t("regenerateDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="regenerate-instruction">{t("regenerateInstructionLabel")}</Label>
<Textarea
id="regenerate-instruction"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder={t("regenerateInstructionPlaceholder")}
rows={3}
maxLength={500}
disabled={regenerating}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={regenerating}>
{t("regenerateCancel")}
</Button>
<Button onClick={handleRegenerate} disabled={regenerating || !instruction.trim()}>
{regenerating
? <><Loader2 className="h-4 w-4 animate-spin" />{t("regenerating")}</>
: <><Wand2 className="h-4 w-4" />{t("regenerateApply")}</>
}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}