feat: persist AI chat history, add search across it

Both chat panels (per-recipe and the general cooking assistant) previously
kept messages in component state only — closing the sheet or reloading
the page lost everything. Both POST routes now persist question+answer
pairs to a new chat_messages table (recipeId null for the general
assistant), and both panels load their own history back on open.

New GET /api/v1/ai/chat-history (scoped by recipeId, or scope=general for
the homepage assistant, or neither to search across everything) backs a
new search control in both panels' header — debounced, shows matched
messages with date and (when searching across everything) which recipe
they belonged to.

Verified locally: asked a real question, closed and reopened the panel
and confirmed the conversation reloaded, then searched a keyword from
that conversation and confirmed both the question and answer surfaced
in the results dropdown.
This commit is contained in:
Arnaud
2026-07-12 22:37:39 +02:00
parent 0062220d8e
commit 96ef5b8379
8 changed files with 237 additions and 9 deletions
@@ -8,12 +8,15 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
import { MessageCircle, Send, Bot, User } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import { ChatHistorySearch } from "./chat-history-search";
type Message = {
role: "user" | "assistant";
content: string;
};
type HistoryEntry = { role: "user" | "assistant"; content: string };
type Props = {
recipeId: string;
recipeTitle: string;
@@ -26,6 +29,18 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const historyLoadedRef = useRef(false);
useEffect(() => {
if (!open || historyLoadedRef.current) return;
historyLoadedRef.current = true;
fetch(`/api/v1/ai/chat-history?recipeId=${recipeId}&limit=50`)
.then((res) => (res.ok ? res.json() : null))
.then((json: { data?: HistoryEntry[] } | null) => {
if (json?.data?.length) setMessages(json.data.map((m) => ({ role: m.role, content: m.content })));
})
.catch(() => {});
}, [open, recipeId]);
useEffect(() => {
if (open && bottomRef.current) {
@@ -85,10 +100,13 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full sm:w-[420px] flex flex-col p-0">
<SheetHeader className="px-4 py-3 pr-10 border-b shrink-0">
<SheetTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{t("title")}
</SheetTitle>
<div className="flex items-center justify-between gap-2">
<SheetTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{t("title")}
</SheetTitle>
<ChatHistorySearch recipeId={recipeId} />
</div>
<p className="text-xs text-muted-foreground text-left">{recipeTitle}</p>
</SheetHeader>