2ffa05e4cf
The existing recipe chat (RecipeChatPanel) is tightly scoped to one recipe — recipeId is required and its system prompt embeds that recipe's full ingredient/step context. Added a sibling, not a modification: a new floating assistant on the recipes list page for questions that aren't about any specific recipe (technique, substitutions, timing, food safety). New /api/v1/ai/cooking-chat route (same quota/rate-limit/model-resolution pattern as recipe-chat, minus the recipe lookup) and CookingAssistantPanel component (same floating-button + Sheet UI). Positioned at bottom-24 rather than bottom-6 so it doesn't sit in the same band as the recipe list's floating bulk-action bar during select mode. Verified locally: asked a real cooking question through the actual UI, got a correctly-formatted markdown answer with no recipe context leaking in (confirmed the request payload only carries the question, no recipeId).
180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
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 } from "lucide-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Message = {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
};
|
|
|
|
export function CookingAssistantPanel() {
|
|
const t = useTranslations("cookingChat");
|
|
const [open, setOpen] = useState(false);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (open && bottomRef.current) {
|
|
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}, [messages, open]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const question = input.trim();
|
|
if (!question || loading) return;
|
|
|
|
setInput("");
|
|
setMessages((prev) => [...prev, { role: "user", content: question }]);
|
|
setLoading(true);
|
|
|
|
try {
|
|
const res = await fetch("/api/v1/ai/cooking-chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ question }),
|
|
});
|
|
const data = await res.json() as { answer?: string; error?: string };
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{ role: "assistant", content: data.answer ?? t("sorry") },
|
|
]);
|
|
} catch {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{ role: "assistant", content: t("error") },
|
|
]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const suggestions = [
|
|
t("suggestion1"),
|
|
t("suggestion2"),
|
|
t("suggestion3"),
|
|
t("suggestion4"),
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="default"
|
|
size="icon"
|
|
// bottom-24 (not bottom-6, like the per-recipe chat button) clears the
|
|
// recipe list's floating bulk-action bar, which occupies the bottom-4
|
|
// to bottom-8 band while select mode is active.
|
|
className="fixed bottom-24 right-6 h-12 w-12 rounded-full shadow-lg z-40"
|
|
onClick={() => setOpen(true)}
|
|
aria-label={t("ariaLabel")}
|
|
>
|
|
<MessageCircle className="h-5 w-5" />
|
|
</Button>
|
|
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetContent side="right" className="w-full sm:w-[420px] flex flex-col p-0">
|
|
<SheetHeader className="px-4 py-3 pr-10 border-b shrink-0">
|
|
<SheetTitle className="text-base flex items-center gap-2">
|
|
<Bot className="h-4 w-4 text-primary" />
|
|
{t("title")}
|
|
</SheetTitle>
|
|
<p className="text-xs text-muted-foreground text-left">{t("subtitle")}</p>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
|
|
{messages.length === 0 && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
{t("intro")}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{suggestions.map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setInput(s)}
|
|
className="w-full text-left text-sm px-3 py-2 rounded-lg border bg-muted/50 hover:bg-muted transition-colors"
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg, i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"flex gap-2 items-start",
|
|
msg.role === "user" && "flex-row-reverse"
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground"
|
|
)}>
|
|
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
|
</div>
|
|
<div className={cn(
|
|
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
|
|
msg.role === "user"
|
|
? "bg-primary text-primary-foreground whitespace-pre-wrap"
|
|
: "bg-muted space-y-2 [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-4 [&_ol]:pl-4 [&_strong]:font-semibold [&_li]:my-0.5"
|
|
)}>
|
|
{msg.role === "assistant" ? (
|
|
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
|
) : (
|
|
msg.content
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{loading && (
|
|
<div className="flex gap-2 items-start">
|
|
<div className="h-7 w-7 shrink-0 rounded-full flex items-center justify-center bg-muted text-muted-foreground">
|
|
<Bot className="h-3.5 w-3.5" />
|
|
</div>
|
|
<div className="rounded-xl px-3 py-2 bg-muted">
|
|
<span className="flex gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
|
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
|
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="px-4 py-3 border-t shrink-0 flex gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder={t("inputPlaceholder")}
|
|
disabled={loading}
|
|
className="flex-1"
|
|
autoComplete="off"
|
|
/>
|
|
<Button type="submit" size="icon" disabled={loading || !input.trim()} aria-label={t("send")}>
|
|
<Send className="h-4 w-4" />
|
|
</Button>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|