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:
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.41.0",
|
||||
"version": "0.42.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user