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
+24
View File
@@ -5,22 +5,46 @@ import { recipes } from "./recipes";
export const chatRoleEnum = pgEnum("chat_role", ["user", "assistant"]);
// The general cooking assistant (recipeId null on chatMessages) can have
// multiple named conversations per user — the per-recipe chat doesn't need
// this, since each recipe is naturally its own single thread already.
export const aiConversations = pgTable("ai_conversations", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
}, (t) => [
index("ai_conversations_user_idx").on(t.userId, t.updatedAt),
]);
export const aiConversationsRelations = relations(aiConversations, ({ one, many }) => ({
user: one(users, { fields: [aiConversations.userId], references: [users.id] }),
messages: many(chatMessages),
}));
// Persists both the per-recipe chat (recipeId set) and the general cooking
// assistant on the recipes homepage (recipeId null) — same table, since a
// user searching "their chat history" expects both to show up together.
// conversationId is only set for general-assistant messages created after
// multi-conversation support shipped; older general messages and all
// per-recipe messages leave it null.
export const chatMessages = pgTable("chat_messages", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").references(() => recipes.id, { onDelete: "cascade" }),
conversationId: text("conversation_id").references(() => aiConversations.id, { onDelete: "cascade" }),
role: chatRoleEnum("role").notNull(),
content: text("content").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("chat_messages_user_idx").on(t.userId, t.createdAt),
index("chat_messages_recipe_idx").on(t.recipeId),
index("chat_messages_conversation_idx").on(t.conversationId),
]);
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
user: one(users, { fields: [chatMessages.userId], references: [users.id] }),
recipe: one(recipes, { fields: [chatMessages.recipeId], references: [recipes.id] }),
conversation: one(aiConversations, { fields: [chatMessages.conversationId], references: [aiConversations.id] }),
}));