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
@@ -0,0 +1,13 @@
CREATE TABLE "ai_conversations" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"title" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "chat_messages" ADD COLUMN "conversation_id" text;--> statement-breakpoint
ALTER TABLE "ai_conversations" ADD CONSTRAINT "ai_conversations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_conversations_user_idx" ON "ai_conversations" USING btree ("user_id","updated_at");--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_conversation_id_ai_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."ai_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "chat_messages_conversation_idx" ON "chat_messages" USING btree ("conversation_id");
File diff suppressed because it is too large Load Diff
@@ -295,6 +295,13 @@
"when": 1784300027960,
"tag": "0041_perfect_dexter_bennett",
"breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1784301168549,
"tag": "0042_windy_masked_marvel",
"breakpoints": true
}
]
}
+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] }),
}));