feat: multiple named conversations for the cooking assistant

The general assistant had exactly one conversation per user forever
(recipeId null on chat_messages) — no way to start fresh or organize
by topic. Adds an ai_conversations table (title, timestamps) and a
nullable conversationId FK on chat_messages; the assistant panel gets
a conversations menu to create/switch/rename/delete, auto-titling a
new conversation from its first question.

Per-recipe chat is untouched — each recipe already has one natural
thread, so multi-conversation support only applies to the homepage
assistant. Pre-existing general messages (no conversationId) aren't
migrated into the new model and won't appear in the conversation list.

Requires migration 0042 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate`.

v0.43.0
This commit is contained in:
Arnaud
2026-07-17 17:18:49 +02:00
parent 25e624f618
commit c5a8f94b26
17 changed files with 5671 additions and 18 deletions
+4 -1
View File
@@ -5,6 +5,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth";
const Schema = z.object({
recipeId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
// "general" restricts to the homepage cooking assistant (recipeId null);
// omit both recipeId and scope to search across everything.
scope: z.enum(["general"]).optional(),
@@ -19,6 +20,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const parsed = Schema.safeParse({
recipeId: searchParams.get("recipeId") ?? undefined,
conversationId: searchParams.get("conversationId") ?? undefined,
scope: searchParams.get("scope") ?? undefined,
q: searchParams.get("q") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
@@ -26,10 +28,11 @@ export async function GET(req: NextRequest) {
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const { recipeId, scope, q, limit } = parsed.data;
const { recipeId, conversationId, scope, q, limit } = parsed.data;
const conditions = [eq(chatMessages.userId, session!.user.id)];
if (recipeId) conditions.push(eq(chatMessages.recipeId, recipeId));
else if (conversationId) conditions.push(eq(chatMessages.conversationId, conversationId));
else if (scope === "general") conditions.push(isNull(chatMessages.recipeId));
if (q?.trim()) conditions.push(ilike(chatMessages.content, `%${q.trim()}%`));
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, aiConversations, eq, and } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const RenameSchema = z.object({ title: z.string().max(100).nullable() });
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { id } = await params;
const body = await req.json() as unknown;
const parsed = RenameSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const [updated] = await db
.update(aiConversations)
.set({ title: parsed.data.title?.trim() || null, updatedAt: new Date() })
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
.returning();
if (!updated) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ id: updated.id, title: updated.title });
}
export async function DELETE(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const { id } = await params;
const [deleted] = await db
.delete(aiConversations)
.where(and(eq(aiConversations.id, id), eq(aiConversations.userId, session!.user.id)))
.returning({ id: aiConversations.id });
if (!deleted) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { db, aiConversations, eq, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
export async function GET(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const rows = await db.query.aiConversations.findMany({
where: eq(aiConversations.userId, session!.user.id),
orderBy: desc(aiConversations.updatedAt),
});
return NextResponse.json({
data: rows.map((r) => ({
id: r.id,
title: r.title,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
});
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const id = crypto.randomUUID();
const now = new Date();
await db.insert(aiConversations).values({ id, userId: session!.user.id, title: null, createdAt: now, updatedAt: now });
return NextResponse.json({ id, title: null, createdAt: now.toISOString(), updatedAt: now.toISOString() }, { status: 201 });
}
+18 -3
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { db, chatMessages } from "@epicure/db";
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";
@@ -11,6 +11,7 @@ import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
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" };
@@ -48,10 +49,24 @@ export async function POST(req: NextRequest) {
);
if (!result.ok) return result.response;
const { conversationId } = parsed.data;
void db.insert(chatMessages).values([
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, role: "user", content: parsed.data.question },
{ id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, role: "assistant", content: result.data.text },
{ 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 });
}