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) {