c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
79 lines
5.0 KiB
TypeScript
79 lines
5.0 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { generateText, stepCountIs } from "ai";
|
|
import { db, chatMessages, aiConversations, sql } from "@epicure/db";
|
|
import { requireSessionOrApiKey } 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";
|
|
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),
|
|
conversationId: z.string().uuid().optional(),
|
|
});
|
|
|
|
const LANG: Record<string, string> = { en: "English", fr: "French" };
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const { session, response } = await requireSessionOrApiKey(req);
|
|
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 aiConfig = configResult.data;
|
|
const model = resolveModel(aiConfig);
|
|
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" | "family", () =>
|
|
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 have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.${bioContext}`,
|
|
prompt: parsed.data.question,
|
|
tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool },
|
|
stopWhen: stepCountIs(3),
|
|
}), { skipQuota: aiConfig.isByok }
|
|
);
|
|
if (!result.ok) return result.response;
|
|
|
|
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 },
|
|
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "assistant", content: result.data.text },
|
|
]).catch((err) => console.error("[cooking-chat] failed to persist chat history", err));
|
|
|
|
if (conversationId) {
|
|
// Bumps updatedAt (for the conversation list's sort order) and, only if
|
|
// this is the conversation's first message, auto-titles it from the
|
|
// opening question — so users aren't left staring at "Untitled" entries;
|
|
// they can still rename it later.
|
|
void db.execute(sql`
|
|
UPDATE ${aiConversations}
|
|
SET updated_at = now(), title = COALESCE(title, ${parsed.data.question.slice(0, 60)})
|
|
WHERE ${aiConversations.id} = ${conversationId} AND ${aiConversations.userId} = ${session!.user.id}
|
|
`).catch((err) => console.error("[cooking-chat] failed to touch conversation", err));
|
|
}
|
|
|
|
return NextResponse.json({ answer: result.data.text, proposedRecipe, proposedShoppingList });
|
|
}
|