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.
|
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
|
## 0.41.0 — 2026-07-17 14:45
|
||||||
|
|
||||||
### Added
|
### 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";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { DietaryTagPicker } from "./dietary-tag-picker";
|
import { DietaryTagPicker } from "./dietary-tag-picker";
|
||||||
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
||||||
|
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
|
||||||
|
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
|
||||||
|
|
||||||
type DietaryTags = {
|
type DietaryTags = {
|
||||||
vegan?: boolean;
|
vegan?: boolean;
|
||||||
@@ -217,6 +219,31 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
|
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) {
|
function addTag(raw: string) {
|
||||||
const tag = raw.trim().toLowerCase().slice(0, 50);
|
const tag = raw.trim().toLowerCase().slice(0, 50);
|
||||||
if (!tag || tags.includes(tag) || tags.length >= 20) return;
|
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">
|
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl pb-20">
|
||||||
{/* Basic info */}
|
{/* Basic info */}
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">{t("titleLabel")}</Label>
|
<Label htmlFor="title">{t("titleLabel")}</Label>
|
||||||
<Input
|
<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.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.41.0",
|
||||||
date: "2026-07-17 14:45",
|
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: "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/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/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 } } } } });
|
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 ---
|
// --- AI (Phase 3): recipe generation, transforms, chat ---
|
||||||
|
|||||||
@@ -111,6 +111,16 @@
|
|||||||
"adaptButton": "Adapt recipe",
|
"adaptButton": "Adapt recipe",
|
||||||
"clearExclusions": "Clear",
|
"clearExclusions": "Clear",
|
||||||
"variationsDirectionsPlaceholder": "e.g. make it vegan, use only pantry staples, reduce sugar…",
|
"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",
|
"historyRestoreFailed": "Failed to restore version",
|
||||||
"pairingMealFailed": "Failed to suggest pairings",
|
"pairingMealFailed": "Failed to suggest pairings",
|
||||||
"pairingDrinkFailed": "Failed to suggest drinks",
|
"pairingDrinkFailed": "Failed to suggest drinks",
|
||||||
|
|||||||
@@ -112,6 +112,16 @@
|
|||||||
"clearExclusions": "Effacer",
|
"clearExclusions": "Effacer",
|
||||||
"adaptConstraintPlaceholder": "ex. Rendre végétalien, réduire les calories, utiliser seulement les produits du garde-manger, sans gluten…",
|
"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…",
|
"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",
|
"historyRestoreFailed": "Échec de la restauration de la version",
|
||||||
"pairingMealFailed": "Échec de la suggestion d'accompagnements",
|
"pairingMealFailed": "Échec de la suggestion d'accompagnements",
|
||||||
"pairingDrinkFailed": "Échec de la suggestion de boissons",
|
"pairingDrinkFailed": "Échec de la suggestion de boissons",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.41.0",
|
"version": "0.42.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user