From 982d4e32644340e9610813cddf2c65d91714e441 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 17 Jul 2026 18:26:19 +0200 Subject: [PATCH] feat: cooking assistant can propose creating a recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 7 + apps/web/app/api/v1/ai/cooking-chat/route.ts | 10 +- .../recipe/cooking-assistant-panel.tsx | 165 +++++++++++++++--- apps/web/lib/ai/tools/create-recipe-tool.ts | 38 ++++ apps/web/lib/changelog.ts | 10 +- apps/web/lib/openapi.ts | 9 +- apps/web/messages/en.json | 8 +- apps/web/messages/fr.json | 8 +- apps/web/package.json | 2 +- package.json | 2 +- 10 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 apps/web/lib/ai/tools/create-recipe-tool.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff67a4..b281e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/api/v1/ai/cooking-chat/route.ts b/apps/web/app/api/v1/ai/cooking-chat/route.ts index d1aabb8..0c3c5ee 100644 --- a/apps/web/app/api/v1/ai/cooking-chat/route.ts +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -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 }); } diff --git a/apps/web/components/recipe/cooking-assistant-panel.tsx b/apps/web/components/recipe/cooking-assistant-panel.tsx index 95fa961..f4228cf 100644 --- a/apps/web/components/recipe/cooking-assistant-panel.tsx +++ b/apps/web/components/recipe/cooking-assistant-panel.tsx @@ -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([]); @@ -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) => ( -
-
- {msg.role === "user" ? : } -
-
- {msg.role === "assistant" ? ( - {msg.content} - ) : ( - msg.content +
+
+
+ {msg.role === "user" ? : } +
+
+ {msg.role === "assistant" ? ( + {msg.content} + ) : ( + msg.content + )} +
+ + {msg.proposedRecipe && ( +
+
+ + {msg.proposedRecipe.title} +
+

+ {t("proposalSummary", { + ingredients: msg.proposedRecipe.ingredients.length, + steps: msg.proposedRecipe.steps.length, + })} +

+ {msg.proposalStatus === "created" ? ( +

{t("proposalCreated")}

+ ) : msg.proposalStatus === "discarded" ? ( +

{t("proposalDiscarded")}

+ ) : ( +
+ + +
+ )} +
+ )}
))} diff --git a/apps/web/lib/ai/tools/create-recipe-tool.ts b/apps/web/lib/ai/tools/create-recipe-tool.ts new file mode 100644 index 0000000..72d198e --- /dev/null +++ b/apps/web/lib/ai/tools/create-recipe-tool.ts @@ -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; + +/** + * 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, +}); diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 5d8f133..eddab30 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index a78572d..0c96269 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -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(), })); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8f8f1eb..aef7bf8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 0841d6c..b847b90 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index cc64315..dfeb46f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.44.1", + "version": "0.45.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 524880d..e12874f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.44.1", + "version": "0.45.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",