feat: cooking assistant can propose creating a recipe

Gives the general chat a createRecipe tool (Vercel AI SDK, stepCountIs(3))
scoped so it can only ever produce a draft — the tool's execute is a pure
echo, no DB write. The route surfaces the tool call as proposedRecipe
alongside the normal text answer; the chat UI renders it as a card with
explicit Create/Discard buttons. Create POSTs to the existing
/api/v1/recipes endpoint — the same code path and tier/recipe-count limit
the manual editor already goes through — so there's exactly one place
that actually creates a recipe row, and nothing happens without the user
clicking Create.

Scoped to the general assistant only (not per-recipe chat), and to one
tool for now — addToShoppingList/generateMealPlan are follow-ups.

v0.45.0
This commit is contained in:
Arnaud
2026-07-17 18:26:19 +02:00
parent 7d290c5b1f
commit 982d4e3264
10 changed files with 222 additions and 37 deletions
+7
View File
@@ -2,6 +2,13 @@
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.45.0 — 2026-07-17 17:30
### Added
- The cooking assistant can now propose creating a recipe when you explicitly ask it to ("make me a recipe for X", "write that down") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.
Note: this is the first step of chatbot-driven actions — only recipe creation for now. Adding to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.
## 0.44.1 — 2026-07-17 17:00
### Fixed
+7 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { generateText, stepCountIs } from "ai";
import { db, chatMessages, aiConversations, sql } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
@@ -8,6 +8,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
import { createRecipeTool } from "@/lib/ai/tools/create-recipe-tool";
const Schema = z.object({
question: z.string().min(1).max(500),
@@ -43,13 +44,16 @@ export async function POST(req: NextRequest) {
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "team", () =>
generateText({
model,
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${bioContext}`,
system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou can propose creating a recipe with the createRecipe tool when the user explicitly asks you to create, save, or write down a recipe — e.g. "make me a recipe for X" or "write that down as a recipe". Don't call it just because a recipe came up in conversation. When you do call it, still write a short text reply too (e.g. "Here's a draft — check it below and confirm if it looks right"), since calling the tool only drafts the recipe for the user to review; it never saves anything by itself.${bioContext}`,
prompt: parsed.data.question,
tools: { createRecipe: createRecipeTool },
stopWhen: stepCountIs(3),
}), { skipQuota: aiConfig.isByok }
);
if (!result.ok) return result.response;
const { conversationId } = parsed.data;
const proposedRecipe = result.data.toolCalls.find((c) => c.toolName === "createRecipe")?.input;
void db.insert(chatMessages).values([
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question },
@@ -68,5 +72,5 @@ export async function POST(req: NextRequest) {
`).catch((err) => console.error("[cooking-chat] failed to touch conversation", err));
}
return NextResponse.json({ answer: result.data.text });
return NextResponse.json({ answer: result.data.text, proposedRecipe });
}
@@ -1,19 +1,35 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { MessageCircle, Send, Bot, User, Maximize2, Minimize2 } from "lucide-react";
import { MessageCircle, Send, Bot, User, Maximize2, Minimize2, ChefHat, Check, X, Loader2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import { ChatHistorySearch } from "./chat-history-search";
import { ConversationMenu, type Conversation } from "./conversation-menu";
type RecipeProposal = {
title: string;
description?: string;
baseServings: number;
recipeType: "dish" | "drink";
difficulty?: "easy" | "medium" | "hard";
prepMins?: number;
cookMins?: number;
ingredients: Array<{ rawName: string; quantity?: number; unit?: string }>;
steps: Array<{ instruction: string }>;
};
type Message = {
role: "user" | "assistant";
content: string;
proposedRecipe?: RecipeProposal;
proposalStatus?: "pending" | "creating" | "created" | "discarded";
};
type HistoryEntry = { role: "user" | "assistant"; content: string };
@@ -21,6 +37,7 @@ type HistoryEntry = { role: "user" | "assistant"; content: string };
export function CookingAssistantPanel() {
const t = useTranslations("cookingChat");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
@@ -102,10 +119,15 @@ export function CookingAssistantPanel() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question, conversationId: conversationId ?? undefined }),
});
const data = await res.json() as { answer?: string; error?: string };
const data = await res.json() as { answer?: string; error?: string; proposedRecipe?: RecipeProposal };
setMessages((prev) => [
...prev,
{ role: "assistant", content: data.answer ?? t("sorry") },
{
role: "assistant",
content: data.answer ?? t("sorry"),
proposedRecipe: data.proposedRecipe,
proposalStatus: data.proposedRecipe ? "pending" : undefined,
},
]);
} catch {
setMessages((prev) => [
@@ -117,6 +139,49 @@ export function CookingAssistantPanel() {
}
};
async function handleCreateProposal(index: number) {
const proposal = messages[index]?.proposedRecipe;
if (!proposal) return;
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "creating" } : m)));
try {
const res = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: proposal.title,
description: proposal.description,
baseServings: proposal.baseServings,
recipeType: proposal.recipeType,
difficulty: proposal.difficulty,
prepMins: proposal.prepMins,
cookMins: proposal.cookMins,
visibility: "private",
aiGenerated: true,
ingredients: proposal.ingredients.map((ing, i) => ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, order: i })),
steps: proposal.steps.map((step, i) => ({ instruction: step.instruction, order: i })),
}),
});
if (!res.ok) {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "pending" } : m)));
toast.error(t("proposalCreateFailed"));
return;
}
const { id } = await res.json() as { id: string };
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "created" } : m)));
toast.success(t("proposalCreated"));
router.push(`/recipes/${id}/edit`);
} catch {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "pending" } : m)));
toast.error(t("proposalCreateFailed"));
}
}
function handleDiscardProposal(index: number) {
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "discarded" } : m)));
}
const suggestions = [
t("suggestion1"),
t("suggestion2"),
@@ -195,33 +260,77 @@ export function CookingAssistantPanel() {
)}
{messages.map((msg, i) => (
<div
key={i}
className={cn(
"flex gap-2 items-start",
msg.role === "user" && "flex-row-reverse"
)}
>
<div className={cn(
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
<div className={cn(
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
msg.role === "user"
? "bg-primary text-primary-foreground whitespace-pre-wrap"
: "bg-muted space-y-2 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-4 [&_ol]:pl-4 [&_strong]:font-semibold [&_li]:my-0.5"
)}>
{msg.role === "assistant" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
<div key={i} className="space-y-2">
<div
className={cn(
"flex gap-2 items-start",
msg.role === "user" && "flex-row-reverse"
)}
>
<div className={cn(
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
<div className={cn(
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
msg.role === "user"
? "bg-primary text-primary-foreground whitespace-pre-wrap"
: "bg-muted space-y-2 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-4 [&_ol]:pl-4 [&_strong]:font-semibold [&_li]:my-0.5"
)}>
{msg.role === "assistant" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
{msg.proposedRecipe && (
<div className="ml-9 rounded-lg border bg-card p-3 space-y-2 max-w-[80%]">
<div className="flex items-center gap-2">
<ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="font-medium text-sm truncate">{msg.proposedRecipe.title}</span>
</div>
<p className="text-xs text-muted-foreground">
{t("proposalSummary", {
ingredients: msg.proposedRecipe.ingredients.length,
steps: msg.proposedRecipe.steps.length,
})}
</p>
{msg.proposalStatus === "created" ? (
<p className="text-xs text-primary flex items-center gap-1"><Check className="h-3 w-3" />{t("proposalCreated")}</p>
) : msg.proposalStatus === "discarded" ? (
<p className="text-xs text-muted-foreground flex items-center gap-1"><X className="h-3 w-3" />{t("proposalDiscarded")}</p>
) : (
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs gap-1"
disabled={msg.proposalStatus === "creating"}
onClick={() => void handleCreateProposal(i)}
>
{msg.proposalStatus === "creating"
? <Loader2 className="h-3 w-3 animate-spin" />
: <Check className="h-3 w-3" />}
{t("proposalCreateButton")}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={msg.proposalStatus === "creating"}
onClick={() => handleDiscardProposal(i)}
>
{t("proposalDiscardButton")}
</Button>
</div>
)}
</div>
)}
</div>
))}
@@ -0,0 +1,38 @@
import { tool } from "ai";
import { z } from "zod";
const CreateRecipeInputSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
recipeType: z.enum(["dish", "drink"]).default("dish"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1).max(200).describe("Ingredient name only, e.g. 'flour' — never combined with quantity/unit"),
quantity: z.number().optional().describe("A number only, e.g. 0.25, 1.5, 2"),
unit: z.string().max(50).optional().describe("e.g. 'cup', 'tbsp', 'g'"),
})).min(1).max(100),
steps: z.array(z.object({
instruction: z.string().min(1).max(2000),
})).min(1).max(100),
});
export type CreateRecipeToolInput = z.infer<typeof CreateRecipeInputSchema>;
/**
* Lets the cooking assistant propose a new recipe from the conversation.
* Deliberately does NOT write to the database — `execute` just validates and
* echoes the input back as a draft. The chat UI renders that draft with an
* explicit Confirm button, which POSTs to the existing /api/v1/recipes
* endpoint (the same one the manual recipe editor uses) — so nothing gets
* created without the user directly approving it, and there's exactly one
* code path that actually writes a recipe row.
*/
export const createRecipeTool = tool({
description:
"Propose creating a new recipe based on the conversation. This only drafts the recipe for the user to review — it does not save anything. Only call this when the user has actually asked you to create/save/write down a recipe, not just discussed one.",
inputSchema: CreateRecipeInputSchema,
execute: async (input: CreateRecipeToolInput) => input,
});
+9 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.44.1";
export const APP_VERSION = "0.45.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.45.0",
date: "2026-07-17 17:30",
added: [
"The cooking assistant can now propose creating a recipe when you explicitly ask it to (\"make me a recipe for X\", \"write that down\") — it drafts one right in the chat with a Create/Discard choice. Nothing is saved until you confirm; confirming goes through the same recipe-creation endpoint (and the same per-tier recipe limit) as the manual editor.",
],
notes: "First step of chatbot-driven actions — only recipe creation for now. Adding recipes to a shopping list or generating a meal plan from chat are tracked as follow-ups, not yet built.",
},
{
version: "0.44.1",
date: "2026-07-17 17:00",
+8 -1
View File
@@ -313,7 +313,14 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/ai/batch-cook/generate", summary: "Generate a batch-cooking plan (multiple dishes) as a new recipe", description: "Rate-limited: 3 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ dinners: z.number().int().min(0).max(6).default(4), lunches: z.number().int().min(0).max(6).default(0), servings: z.number().int().min(1).max(12).default(4), dietaryPrefs: z.string().max(200).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), description: z.string().max(500).optional() }) } }, required: true } }, responses: { 200: { description: "New recipe id", content: { "application/json": { schema: z.object({ id: z.string() }) } } }, 400: { description: "Validation error or no dinners/lunches chosen", 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/meal-plan/generate", summary: "Generate a week's meal plan (creates a draft recipe per entry)", description: "Rate-limited: 3 req/min. Consumes AI quota. Charges your tier's recipe limit for each generated entry (refunded if the limit is exceeded).", security, request: { body: { content: { "application/json": { schema: z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dietaryPrefs: z.string().max(200).optional(), servings: z.number().int().min(1).max(20).default(2), days: z.array(z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])).min(1).max(7).default(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), usePantry: z.boolean().default(false), pantryMode: z.boolean().default(false), difficulty: z.enum(["easy", "medium", "hard"]).optional(), targetNutritionGoals: z.boolean().default(false) }) } }, required: true } }, responses: { 200: { description: "Created entries", content: { "application/json": { schema: z.object({ weekStart: z.string(), entries: z.array(z.object({ id: z.string(), day: z.string(), mealType: z.string(), recipeId: z.string(), recipeTitle: z.string() })) }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Recipe limit reached for your tier", 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/recipe-ideas", summary: "Generate 6 diverse recipe idea cards (not saved)", description: "Rate-limited: 10 req/min. Consumes AI quota.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().max(300).optional() }) } } } }, responses: { 200: { description: "Six recipe ideas", content: { "application/json": { schema: z.array(z.object({ title: z.string(), description: z.string(), tags: z.array(z.string()).max(4), difficulty: z.enum(["easy", "medium", "hard"]), totalMins: z.number().int().optional() })) } } }, 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/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const ProposedRecipeRef = registry.register("ProposedRecipe", z.object({
title: z.string(), description: z.string().optional(), baseServings: z.number().int(),
recipeType: z.enum(["dish", "drink"]), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().optional(), cookMins: z.number().int().optional(),
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
steps: z.array(z.object({ instruction: z.string() })),
}));
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe, the response may include proposedRecipe — a draft the assistant proposed but did NOT save; the caller must POST it to /api/v1/recipes itself to actually create it.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const AiConversationRef = registry.register("AiConversation", z.object({
id: z.string(), title: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));
+7 -1
View File
@@ -459,7 +459,13 @@
"subtitle": "Ask any cooking question — not tied to a specific recipe",
"intro": "Ask anything about cooking — techniques, substitutions, timing, equipment…",
"inputPlaceholder": "Ask a question…",
"send": "Send message"
"send": "Send message",
"proposalSummary": "{ingredients} ingredients · {steps} steps",
"proposalCreateButton": "Create recipe",
"proposalDiscardButton": "Discard",
"proposalCreated": "Recipe created — opening editor…",
"proposalDiscarded": "Discarded",
"proposalCreateFailed": "Failed to create recipe"
},
"conversations": {
"menuAriaLabel": "Conversations",
+7 -1
View File
@@ -459,7 +459,13 @@
"subtitle": "Posez n'importe quelle question de cuisine — pas liée à une recette précise",
"intro": "Demandez n'importe quoi sur la cuisine — techniques, substitutions, minutage, équipement…",
"inputPlaceholder": "Posez une question…",
"send": "Envoyer le message"
"send": "Envoyer le message",
"proposalSummary": "{ingredients} ingrédients · {steps} étapes",
"proposalCreateButton": "Créer la recette",
"proposalDiscardButton": "Ignorer",
"proposalCreated": "Recette créée — ouverture de l'éditeur…",
"proposalDiscarded": "Ignorée",
"proposalCreateFailed": "Échec de la création de la recette"
},
"conversations": {
"menuAriaLabel": "Conversations",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.44.1",
"version": "0.45.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.44.1",
"version": "0.45.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",