diff --git a/CHANGELOG.md b/CHANGELOG.md index b281e00..0e6c3bc 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.46.0 — 2026-07-17 18:00 + +### Added +- The cooking assistant can now also propose adding items to a shopping list ("add that to my shopping list") — pick an existing list or name a new one, then confirm. Same no-action-without-confirmation pattern as recipe creation, going through the same endpoints the manual "Add to shopping list" button already uses. + +Note: generateMealPlan is still not built as a chat tool — the existing meal-plan-generation endpoint generates and saves in one step with its own input shape (week/preferences, not a specific plan to save), so it doesn't fit the draft-then-confirm pattern the other two tools use without a larger change to that endpoint. + ## 0.45.0 — 2026-07-17 17:30 ### Added 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 0c3c5ee..934fb32 100644 --- a/apps/web/app/api/v1/ai/cooking-chat/route.ts +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -9,6 +9,7 @@ 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"; +import { addToShoppingListTool } from "@/lib/ai/tools/add-to-shopping-list-tool"; const Schema = z.object({ question: z.string().min(1).max(500), @@ -44,9 +45,9 @@ 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}.\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}`, + 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 have two tools, both of which only draft something for the user to review — neither saves anything by itself, so still write a short text reply too when you use one (e.g. "Here's a draft — check it below and confirm if it looks right").\n- createRecipe: only when the user explicitly asks you to create, save, or write down a recipe (e.g. "make me a recipe for X", "write that down as a recipe"). Don't call it just because a recipe came up in conversation.\n- addToShoppingList: only when the user explicitly asks to add ingredients/items to a shopping list (e.g. "add that to my shopping list").${bioContext}`, prompt: parsed.data.question, - tools: { createRecipe: createRecipeTool }, + tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool }, stopWhen: stepCountIs(3), }), { skipQuota: aiConfig.isByok } ); @@ -54,6 +55,7 @@ export async function POST(req: NextRequest) { const { conversationId } = parsed.data; const proposedRecipe = result.data.toolCalls.find((c) => c.toolName === "createRecipe")?.input; + const proposedShoppingList = result.data.toolCalls.find((c) => c.toolName === "addToShoppingList")?.input; void db.insert(chatMessages).values([ { id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question }, @@ -72,5 +74,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, proposedRecipe }); + return NextResponse.json({ answer: result.data.text, proposedRecipe, proposedShoppingList }); } diff --git a/apps/web/components/recipe/cooking-assistant-panel.tsx b/apps/web/components/recipe/cooking-assistant-panel.tsx index f4228cf..52f37be 100644 --- a/apps/web/components/recipe/cooking-assistant-panel.tsx +++ b/apps/web/components/recipe/cooking-assistant-panel.tsx @@ -12,6 +12,7 @@ import ReactMarkdown from "react-markdown"; import { cn } from "@/lib/utils"; import { ChatHistorySearch } from "./chat-history-search"; import { ConversationMenu, type Conversation } from "./conversation-menu"; +import { ShoppingListProposalCard, type ShoppingListProposal } from "./shopping-list-proposal-card"; type RecipeProposal = { title: string; @@ -25,11 +26,15 @@ type RecipeProposal = { steps: Array<{ instruction: string }>; }; +type ProposalStatus = "pending" | "creating" | "created" | "discarded"; + type Message = { role: "user" | "assistant"; content: string; proposedRecipe?: RecipeProposal; - proposalStatus?: "pending" | "creating" | "created" | "discarded"; + proposalStatus?: ProposalStatus; + proposedShoppingList?: ShoppingListProposal; + shoppingProposalStatus?: ProposalStatus; }; type HistoryEntry = { role: "user" | "assistant"; content: string }; @@ -119,7 +124,12 @@ 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; proposedRecipe?: RecipeProposal }; + const data = await res.json() as { + answer?: string; + error?: string; + proposedRecipe?: RecipeProposal; + proposedShoppingList?: ShoppingListProposal; + }; setMessages((prev) => [ ...prev, { @@ -127,6 +137,8 @@ export function CookingAssistantPanel() { content: data.answer ?? t("sorry"), proposedRecipe: data.proposedRecipe, proposalStatus: data.proposedRecipe ? "pending" : undefined, + proposedShoppingList: data.proposedShoppingList, + shoppingProposalStatus: data.proposedShoppingList ? "pending" : undefined, }, ]); } catch { @@ -182,6 +194,10 @@ export function CookingAssistantPanel() { setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, proposalStatus: "discarded" } : m))); } + function handleShoppingProposalStatusChange(index: number, status: ProposalStatus) { + setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, shoppingProposalStatus: status } : m))); + } + const suggestions = [ t("suggestion1"), t("suggestion2"), @@ -331,6 +347,14 @@ export function CookingAssistantPanel() { )} )} + + {msg.proposedShoppingList && msg.shoppingProposalStatus && ( + handleShoppingProposalStatusChange(i, status)} + /> + )} ))} diff --git a/apps/web/components/recipe/shopping-list-proposal-card.tsx b/apps/web/components/recipe/shopping-list-proposal-card.tsx new file mode 100644 index 0000000..d99b349 --- /dev/null +++ b/apps/web/components/recipe/shopping-list-proposal-card.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { ShoppingCart, Check, X, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; + +export type ShoppingListProposal = { + items: Array<{ rawName: string; quantity?: number; unit?: string }>; + suggestedListName?: string; +}; + +type ShoppingList = { id: string; name: string }; + +const NEW_LIST_VALUE = "__new__"; + +/** + * Renders a pending addToShoppingList tool proposal — lets the user pick an + * existing list or name a new one, then confirms through the exact same + * two-endpoint flow AddToShoppingListButton already uses (create list if + * needed, then POST items) rather than a new code path. + */ +export function ShoppingListProposalCard({ + proposal, + status, + onStatusChange, +}: { + proposal: ShoppingListProposal; + status: "pending" | "creating" | "created" | "discarded"; + onStatusChange: (status: "pending" | "creating" | "created" | "discarded") => void; +}) { + const t = useTranslations("recipe"); + const tShopping = useTranslations("shoppingLists"); + const tChat = useTranslations("cookingChat"); + const [lists, setLists] = useState(null); + const [targetListId, setTargetListId] = useState(NEW_LIST_VALUE); + const [newListName, setNewListName] = useState(proposal.suggestedListName || "Shopping List"); + + useEffect(() => { + if (status !== "pending" || lists !== null) return; + fetch("/api/v1/shopping-lists") + .then((res) => (res.ok ? (res.json() as Promise) : [])) + .then((data) => { + setLists(data); + if (data.length > 0) setTargetListId(data[0]!.id); + }) + .catch(() => setLists([])); + }, [status, lists]); + + async function handleConfirm() { + onStatusChange("creating"); + try { + let listId = targetListId; + if (listId === NEW_LIST_VALUE) { + const res = await fetch("/api/v1/shopping-lists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newListName.trim() || "Shopping List" }), + }); + if (!res.ok) throw new Error(); + listId = (await res.json() as { id: string }).id; + } + + const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: proposal.items.map((item) => ({ + rawName: item.rawName, + quantity: item.quantity !== undefined ? String(item.quantity) : undefined, + unit: item.unit, + })), + }), + }); + if (!res.ok) throw new Error(); + + onStatusChange("created"); + toast.success(tShopping("addedCount", { count: proposal.items.length })); + } catch { + onStatusChange("pending"); + toast.error(t("shoppingListAddFailed")); + } + } + + return ( +
+
+ + {tChat("shoppingProposalTitle", { count: proposal.items.length })} +
+
    + {proposal.items.slice(0, 5).map((item, i) => ( +
  • + {item.quantity ? `${item.quantity} ` : ""}{item.unit ? `${item.unit} ` : ""}{item.rawName} +
  • + ))} + {proposal.items.length > 5 &&
  • {tChat("shoppingProposalMore", { count: proposal.items.length - 5 })}
  • } +
+ + {status === "created" ? ( +

{tChat("proposalCreated")}

+ ) : status === "discarded" ? ( +

{tChat("proposalDiscarded")}

+ ) : ( + <> + {lists !== null && ( +
+ + {targetListId === NEW_LIST_VALUE && ( + setNewListName(e.target.value)} + placeholder={tShopping("listNamePlaceholder")} + className="h-7 text-xs" + maxLength={100} + /> + )} +
+ )} +
+ + +
+ + )} +
+ ); +} diff --git a/apps/web/lib/ai/tools/add-to-shopping-list-tool.ts b/apps/web/lib/ai/tools/add-to-shopping-list-tool.ts new file mode 100644 index 0000000..b363267 --- /dev/null +++ b/apps/web/lib/ai/tools/add-to-shopping-list-tool.ts @@ -0,0 +1,27 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const AddToShoppingListInputSchema = z.object({ + items: z.array(z.object({ + rawName: z.string().min(1).max(200).describe("Ingredient/item 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), + suggestedListName: z.string().max(100).optional().describe("Suggested name if the user wants a new list, e.g. 'Weekend BBQ'"), +}); + +export type AddToShoppingListToolInput = z.infer; + +/** + * Lets the cooking assistant propose adding items to a shopping list — same + * safety shape as createRecipeTool: `execute` only validates/echoes the + * input, no DB write. The chat UI resolves which list (existing or new) + * and POSTs through the existing /api/v1/shopping-lists(/{id}/items) + * endpoints, the same ones AddToShoppingListButton already uses. + */ +export const addToShoppingListTool = tool({ + description: + "Propose adding ingredients/items to a shopping list, e.g. from a recipe just discussed. This only drafts the item list for the user to review and pick a target list — it does not save anything. Only call this when the user has actually asked to add items to a shopping list.", + inputSchema: AddToShoppingListInputSchema, + execute: async (input: AddToShoppingListToolInput) => input, +}); diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index eddab30..28e3aba 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.45.0"; +export const APP_VERSION = "0.46.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,14 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.46.0", + date: "2026-07-17 18:00", + added: [ + "The cooking assistant can now also propose adding items to a shopping list (\"add that to my shopping list\") — pick an existing list or name a new one, then confirm. Same no-action-without-confirmation pattern as recipe creation, going through the same endpoints the manual \"Add to shopping list\" button already uses.", + ], + notes: "generateMealPlan is still not built as a chat tool — the existing meal-plan-generation endpoint generates and saves in one step with its own input shape (week/preferences, not a specific plan to save), so it doesn't fit the draft-then-confirm pattern the other two tools use without a larger change to that endpoint.", + }, { version: "0.45.0", date: "2026-07-17 17:30", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 0c96269..48b8e8e 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -320,7 +320,11 @@ export function generateOpenApiSpec(): object { 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 ProposedShoppingListRef = registry.register("ProposedShoppingList", z.object({ + items: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })), + suggestedListName: z.string().optional(), + })); + 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 or add items to a shopping list, the response may include proposedRecipe / proposedShoppingList — drafts the assistant proposed but did NOT save; the caller must POST them itself (to /api/v1/recipes, or /api/v1/shopping-lists + /api/v1/shopping-lists/{id}/items) to actually create anything.", 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(), proposedShoppingList: ProposedShoppingListRef.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 aef7bf8..5c1b16b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -465,7 +465,10 @@ "proposalDiscardButton": "Discard", "proposalCreated": "Recipe created — opening editor…", "proposalDiscarded": "Discarded", - "proposalCreateFailed": "Failed to create recipe" + "proposalCreateFailed": "Failed to create recipe", + "shoppingProposalTitle": "{count, plural, one {1 item} other {{count} items}} for your shopping list", + "shoppingProposalMore": "+{count} more", + "shoppingProposalAddButton": "Add to list" }, "conversations": { "menuAriaLabel": "Conversations", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index b847b90..bcd15ed 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -465,7 +465,10 @@ "proposalDiscardButton": "Ignorer", "proposalCreated": "Recette créée — ouverture de l'éditeur…", "proposalDiscarded": "Ignorée", - "proposalCreateFailed": "Échec de la création de la recette" + "proposalCreateFailed": "Échec de la création de la recette", + "shoppingProposalTitle": "{count, plural, one {1 article} other {{count} articles}} pour votre liste de courses", + "shoppingProposalMore": "+{count} de plus", + "shoppingProposalAddButton": "Ajouter à la liste" }, "conversations": { "menuAriaLabel": "Conversations", diff --git a/apps/web/package.json b/apps/web/package.json index dfeb46f..b9ebb7e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.45.0", + "version": "0.46.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index e12874f..9069038 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.45.0", + "version": "0.46.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",