feat: general cooking-question chatbot on the recipes homepage
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).
This commit is contained in:
@@ -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
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CookingAssistantPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user