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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.45.0",
|
||||
"version": "0.46.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.45.0",
|
||||
"version": "0.46.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user