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
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.42.0 — 2026-07-17 15:15
### Added
- Recipe editor now has "Regenerate with AI" — describe what to change (e.g. "make it spicier", "swap in chicken") and the draft you're editing updates in place, no new recipe created. You still need to save.
## 0.41.0 — 2026-07-17 14:45
### Added
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { regenerateRecipe } from "@/lib/ai/features/regenerate-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
const Schema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.union([z.string(), z.number()]).optional(),
unit: z.string().max(50).optional(),
})).max(100),
steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100),
instruction: z.string().min(1).max(500),
language: z.string().max(10).default("en"),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
// No recipeId, no DB access — this is a stateless AI transform over whatever
// draft the editor currently holds (including unsaved edits), not the saved
// row. The caller merges the result into their own in-progress form state.
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const configResult = await resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const { instruction, language, ...current } = parsed.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
regenerateRecipe(current, instruction, { ...aiConfig, language }), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
return NextResponse.json(result.data);
}
@@ -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>
</>
);
}
@@ -0,0 +1,61 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RegeneratedRecipeSchema = z.object({
title: z.string(),
description: z.string().optional(),
baseServings: z.number().int().min(1).max(100),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
});
export type RegeneratedRecipe = z.infer<typeof RegeneratedRecipeSchema>;
const LANG: Record<string, string> = { en: "English", fr: "French" };
/** Revises a recipe draft already open in the editor per a free-text
* instruction — "add more spice", "make it vegetarian", "double the yield".
* Unlike adaptRecipe/suggestVariations, this never creates a new recipe or
* variation record; it returns a full replacement draft for the caller to
* merge into the in-progress form state, which the user still has to save. */
export async function regenerateRecipe(
current: {
title: string;
description?: string | null;
baseServings: number;
difficulty?: string | null;
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null }>;
steps: Array<{ instruction: string }>;
},
instruction: string,
config?: AiConfig & { language?: string }
): Promise<RegeneratedRecipe> {
const model = resolveModel(config);
const lang = LANG[config?.language ?? "en"] ?? "English";
const langInstruction = lang !== "English" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : "";
const currentText = [
`Title: ${current.title}`,
current.description ? `Description: ${current.description}` : "",
`Servings: ${current.baseServings}`,
current.difficulty ? `Difficulty: ${current.difficulty}` : "",
`Ingredients:\n${current.ingredients.map((i) => ` - ${i.quantity ? `${i.quantity} ` : ""}${i.unit ? `${i.unit} ` : ""}${i.rawName}`).join("\n")}`,
`Steps:\n${current.steps.map((s, i) => ` ${i + 1}. ${s.instruction}`).join("\n")}`,
].filter(Boolean).join("\n");
const { object } = await generateObject({
model,
schema: RegeneratedRecipeSchema,
system:
`You are revising an existing recipe draft per the user's instruction. Keep everything unchanged except what the instruction requires you to change — this is an edit, not a fresh rewrite. Return the complete updated recipe, not just the changed parts. For ingredient quantities: use numbers only, units separately.${langInstruction}`,
prompt: `Current recipe draft:\n${currentText}\n\nInstruction: ${instruction}`,
});
return object;
}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.41.0";
export const APP_VERSION = "0.42.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.42.0",
date: "2026-07-17 15:15",
added: [
"Recipe editor now has \"Regenerate with AI\" — describe what to change (e.g. \"make it spicier\", \"swap in chicken\") and the draft you're editing updates in place, no new recipe created. You still need to save.",
],
},
{
version: "0.41.0",
date: "2026-07-17 14:45",
+1
View File
@@ -292,6 +292,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Get reaction counts (+ your own reactions, if authenticated)", security: [], request: { params: z.object({ id: z.string(), commentId: z.string() }) }, responses: { 200: { description: "Counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), userReactions: z.array(z.string()) }) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/recipes/{id}/comments/{commentId}/reactions", summary: "Toggle a reaction on a comment", description: "Rate-limited: 60 req/min.", security, request: { params: z.object({ id: z.string(), commentId: z.string() }), body: { content: { "application/json": { schema: z.object({ type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]) }) } }, required: true } }, responses: { 200: { description: "Updated counts", content: { "application/json": { schema: z.object({ counts: z.record(z.string(), z.number()), added: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(3).max(500), language: z.string().max(10).default("en"), difficulty: z.enum(["easy", "medium", "hard"]).optional(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/regenerate", summary: "Revise a recipe draft per a free-text instruction (recipe editor's \"Regenerate with AI\")", description: "Rate-limited: 10 req/min. Consumes AI quota. Stateless — takes the current draft's fields directly (not a recipeId), so it reflects unsaved editor changes; does not write to the database.", security, request: { body: { content: { "application/json": { schema: z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100), difficulty: z.enum(["easy", "medium", "hard"]).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.string(), z.number()]).optional(), unit: z.string().max(50).optional() })).max(100), steps: z.array(z.object({ instruction: z.string().min(1).max(2000) })).max(100), instruction: z.string().min(1).max(500), language: z.string().max(10).default("en"), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Revised draft", content: { "application/json": { schema: AiGeneratedRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url(), provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(), model: z.string().optional() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- AI (Phase 3): recipe generation, transforms, chat ---
+10
View File
@@ -111,6 +111,16 @@
"adaptButton": "Adapt recipe",
"clearExclusions": "Clear",
"variationsDirectionsPlaceholder": "e.g. make it vegan, use only pantry staples, reduce sugar…",
"regenerateWithAi": "Regenerate with AI",
"regenerateTitle": "Regenerate with AI",
"regenerateDescription": "Describe what to change — the rest of the recipe stays as-is. This updates the draft you're editing; you'll still need to save.",
"regenerateInstructionLabel": "What should change?",
"regenerateInstructionPlaceholder": "e.g. make it spicier, swap in chicken instead of beef, halve the servings…",
"regenerateCancel": "Cancel",
"regenerating": "Regenerating…",
"regenerateApply": "Regenerate",
"regenerated": "Draft updated — review and save your changes.",
"regenerateFailed": "Failed to regenerate recipe",
"historyRestoreFailed": "Failed to restore version",
"pairingMealFailed": "Failed to suggest pairings",
"pairingDrinkFailed": "Failed to suggest drinks",
+10
View File
@@ -112,6 +112,16 @@
"clearExclusions": "Effacer",
"adaptConstraintPlaceholder": "ex. Rendre végétalien, réduire les calories, utiliser seulement les produits du garde-manger, sans gluten…",
"variationsDirectionsPlaceholder": "ex. rendre végétalien, utiliser uniquement les produits du garde-manger, réduire le sucre…",
"regenerateWithAi": "Régénérer avec l'IA",
"regenerateTitle": "Régénérer avec l'IA",
"regenerateDescription": "Décrivez ce qui doit changer — le reste de la recette reste inchangé. Ceci met à jour le brouillon en cours d'édition ; vous devrez tout de même l'enregistrer.",
"regenerateInstructionLabel": "Qu'est-ce qui doit changer ?",
"regenerateInstructionPlaceholder": "ex. la rendre plus épicée, remplacer le bœuf par du poulet, diviser les portions par deux…",
"regenerateCancel": "Annuler",
"regenerating": "Régénération…",
"regenerateApply": "Régénérer",
"regenerated": "Brouillon mis à jour — vérifiez et enregistrez vos modifications.",
"regenerateFailed": "Échec de la régénération de la recette",
"historyRestoreFailed": "Échec de la restauration de la version",
"pairingMealFailed": "Échec de la suggestion d'accompagnements",
"pairingDrinkFailed": "Échec de la suggestion de boissons",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.41.0",
"version": "0.42.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.41.0",
"version": "0.42.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",