diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index c0a65f0..25075aa 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -7,6 +7,7 @@ import { eq, desc, asc, and, ilike, or, inArray } from "@epicure/db"; import { RecipesHeader } from "@/components/recipe/recipes-header"; import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state"; import { RecipesGrid } from "@/components/recipe/recipes-grid"; +import { CookingAssistantPanel } from "@/components/recipe/cooking-assistant-panel"; import { getMessages } from "@/lib/i18n/server"; export const metadata: Metadata = {}; @@ -139,6 +140,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear )} )} + + ); } diff --git a/apps/web/app/api/v1/ai/cooking-chat/route.ts b/apps/web/app/api/v1/ai/cooking-chat/route.ts new file mode 100644 index 0000000..3635a81 --- /dev/null +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { generateText } from "ai"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; +import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key"; +import { resolveModel } from "@/lib/ai/factory"; +import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio"; + +const Schema = z.object({ + question: z.string().min(1).max(500), +}); + +const LANG: Record = { en: "English", fr: "French" }; + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Validation error" }, { status: 400 }); + } + + const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60); + if (limited) return limited; + + const [configResult, privateBio] = await Promise.all([ + resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")), + getUserPrivateBio(session!.user.id), + ]); + if (!configResult.ok) return configResult.response; + const model = resolveModel(configResult.data); + const bioContext = buildUserBioContext(privateBio); + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const lang = LANG[locale] ?? "English"; + + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => + generateText({ + model, + system: `You are a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${bioContext}`, + prompt: parsed.data.question, + }) + ); + if (!result.ok) return result.response; + + return NextResponse.json({ answer: result.data.text }); +} diff --git a/apps/web/components/recipe/cooking-assistant-panel.tsx b/apps/web/components/recipe/cooking-assistant-panel.tsx new file mode 100644 index 0000000..f71fc7f --- /dev/null +++ b/apps/web/components/recipe/cooking-assistant-panel.tsx @@ -0,0 +1,179 @@ +"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([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(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 ( + <> + + + + + + + + {t("title")} + +

{t("subtitle")}

+
+ +
+ {messages.length === 0 && ( +
+

+ {t("intro")} +

+
+ {suggestions.map((s) => ( + + ))} +
+
+ )} + + {messages.map((msg, i) => ( +
+
+ {msg.role === "user" ? : } +
+
+ {msg.role === "assistant" ? ( + {msg.content} + ) : ( + msg.content + )} +
+
+ ))} + + {loading && ( +
+
+ +
+
+ + + + + +
+
+ )} + +
+
+ +
+ setInput(e.target.value)} + placeholder={t("inputPlaceholder")} + disabled={loading} + className="flex-1" + autoComplete="off" + /> + +
+ + + + ); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f949e5f..dc91d32 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -388,6 +388,20 @@ "inputPlaceholder": "Ask a question…", "send": "Send message" }, + "cookingChat": { + "sorry": "Sorry, I couldn't answer that.", + "error": "Something went wrong. Please try again.", + "suggestion1": "How do I know when oil is hot enough?", + "suggestion2": "What's a good substitute for buttermilk?", + "suggestion3": "How long can I keep leftovers in the fridge?", + "suggestion4": "What's the difference between simmering and boiling?", + "ariaLabel": "Ask a cooking question", + "title": "Cooking assistant", + "subtitle": "Ask any cooking question — not tied to a specific recipe", + "intro": "Ask anything about cooking — techniques, substitutions, timing, equipment…", + "inputPlaceholder": "Ask a question…", + "send": "Send message" + }, "servingScaler": { "servings": "Servings", "reset": "Reset", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 3f6316e..74d5aae 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -388,6 +388,20 @@ "inputPlaceholder": "Posez une question…", "send": "Envoyer le message" }, + "cookingChat": { + "sorry": "Désolé, je n'ai pas pu répondre à cela.", + "error": "Une erreur s'est produite. Veuillez réessayer.", + "suggestion1": "Comment savoir si l'huile est assez chaude ?", + "suggestion2": "Quel est un bon substitut au lait fermenté ?", + "suggestion3": "Combien de temps puis-je garder des restes au frigo ?", + "suggestion4": "Quelle est la différence entre mijoter et bouillir ?", + "ariaLabel": "Poser une question de cuisine", + "title": "Assistant culinaire", + "subtitle": "Posez n'importe quelle question de cuisine — pas liée à une recette précise", + "intro": "Demandez n'importe quoi sur la cuisine — techniques, substitutions, minutage, équipement…", + "inputPlaceholder": "Posez une question…", + "send": "Envoyer le message" + }, "servingScaler": { "servings": "Portions", "reset": "Réinitialiser",