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