diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3629f..bcb34cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/api/v1/ai/regenerate/route.ts b/apps/web/app/api/v1/ai/regenerate/route.ts new file mode 100644 index 0000000..5b5c307 --- /dev/null +++ b/apps/web/app/api/v1/ai/regenerate/route.ts @@ -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); +} diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 54e2ebf..bf4c1ca 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -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) {
{/* Basic info */}
+ {isEdit && ( +
+ ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit })), + steps: steps.map((step) => ({ instruction: step.instruction })), + }} + onRegenerated={applyRegenerated} + /> +
+ )}
; + 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 ( + <> + + + { setOpen(v); if (!v) setInstruction(""); }}> + + + + + {t("regenerateTitle")} + + {t("regenerateDescription")} + + +
+ +