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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user