feat: cooking assistant can propose adding items to a shopping list

Second tool alongside createRecipe, same safety shape: addToShoppingList's
execute only validates/echoes input, no DB write. The proposal card lets
the user pick an existing list (fetched lazily) or name a new one, then
confirms through the exact two-call flow AddToShoppingListButton already
uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new
persistence code path.

generateMealPlan intentionally not built as a tool: the existing
/api/v1/ai/meal-plan/generate endpoint generates and writes in one step
with a week/preferences input, not a specific plan payload, so it has no
"save this exact draft" entry point to confirm against without a larger
refactor. Documented as a known gap rather than forcing a weaker pattern.

v0.46.0
This commit is contained in:
Arnaud
2026-07-17 18:37:07 +02:00
parent 982d4e3264
commit 9eecdbac3c
11 changed files with 244 additions and 11 deletions
+5 -3
View File
@@ -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 });
}
@@ -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() {
)}
</div>
)}
{msg.proposedShoppingList && msg.shoppingProposalStatus && (
<ShoppingListProposalCard
proposal={msg.proposedShoppingList}
status={msg.shoppingProposalStatus}
onStatusChange={(status) => handleShoppingProposalStatusChange(i, status)}
/>
)}
</div>
))}
@@ -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<ShoppingList[] | null>(null);
const [targetListId, setTargetListId] = useState<string>(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<ShoppingList[]>) : []))
.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 (
<div className="ml-9 rounded-lg border bg-card p-3 space-y-2 max-w-[80%]">
<div className="flex items-center gap-2">
<ShoppingCart className="h-3.5 w-3.5 text-primary shrink-0" />
<span className="font-medium text-sm">{tChat("shoppingProposalTitle", { count: proposal.items.length })}</span>
</div>
<ul className="text-xs text-muted-foreground space-y-0.5">
{proposal.items.slice(0, 5).map((item, i) => (
<li key={i} className="truncate">
{item.quantity ? `${item.quantity} ` : ""}{item.unit ? `${item.unit} ` : ""}{item.rawName}
</li>
))}
{proposal.items.length > 5 && <li>{tChat("shoppingProposalMore", { count: proposal.items.length - 5 })}</li>}
</ul>
{status === "created" ? (
<p className="text-xs text-primary flex items-center gap-1"><Check className="h-3 w-3" />{tChat("proposalCreated")}</p>
) : status === "discarded" ? (
<p className="text-xs text-muted-foreground flex items-center gap-1"><X className="h-3 w-3" />{tChat("proposalDiscarded")}</p>
) : (
<>
{lists !== null && (
<div className="space-y-1.5">
<Select value={targetListId} onValueChange={(v) => setTargetListId(v ?? NEW_LIST_VALUE)}>
<SelectTrigger className="h-7 text-xs w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{lists.map((l) => (
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
))}
<SelectItem value={NEW_LIST_VALUE}>{tShopping("modeNew")}</SelectItem>
</SelectContent>
</Select>
{targetListId === NEW_LIST_VALUE && (
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder={tShopping("listNamePlaceholder")}
className="h-7 text-xs"
maxLength={100}
/>
)}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs gap-1"
disabled={status === "creating" || lists === null}
onClick={() => void handleConfirm()}
>
{status === "creating" ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{tChat("shoppingProposalAddButton")}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={status === "creating"}
onClick={() => onStatusChange("discarded")}
>
{tChat("proposalDiscardButton")}
</Button>
</div>
</>
)}
</div>
);
}
@@ -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<typeof AddToShoppingListInputSchema>;
/**
* 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,
});
+9 -1
View File
@@ -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",
+5 -1
View File
@@ -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(),
}));
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.45.0",
"version": "0.46.0",
"private": true,
"scripts": {
"dev": "next dev",