From a2e5c157add2688c6b8010c8079a2505ea439659 Mon Sep 17 00:00:00 2001 From: ff Date: Thu, 18 Jun 2026 17:11:13 -0400 Subject: [PATCH] feat: persist AI chat history to server-side JSON file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat history was stored only in sessionStorage, causing member ID and DOB to be lost when the session expired or corrupted — requiring logout/login. Now history is saved to a per-user JSON file on the backend and loaded on mount, so the LLM always has full conversation context. Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/routes/ai-settings.ts | 49 +++++++++++++++++++ .../src/components/layout/chatbot.tsx | 46 ++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 48379939..9dce8c5b 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -1,9 +1,17 @@ import express, { Request, Response } from "express"; +import fs from "fs"; +import path from "path"; import { storage } from "../storage"; import { classifyInternalChat } from "../ai/internal-chat-graph"; import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow"; import { resolveAiProvider } from "../ai/llm-factory"; +const CHAT_HISTORY_DIR = path.join(__dirname, "..", "..", "chat-history"); + +function getChatHistoryPath(userId: number): string { + return path.join(CHAT_HISTORY_DIR, `user-${userId}.json`); +} + const router = express.Router(); // GET /api/ai/settings @@ -323,4 +331,45 @@ router.post("/create-appointment-today", async (req: Request, res: Response): Pr } }); +// ─── Chat history persistence (JSON file per user) ────────────────────────── + +router.get("/chat-history", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const filePath = getChatHistoryPath(userId); + if (!fs.existsSync(filePath)) return res.status(200).json({ messages: [] }); + const raw = fs.readFileSync(filePath, "utf-8"); + return res.status(200).json(JSON.parse(raw)); + } catch (err) { + return res.status(500).json({ error: "Failed to load chat history", details: String(err) }); + } +}); + +router.put("/chat-history", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { messages } = req.body; + if (!Array.isArray(messages)) return res.status(400).json({ message: "messages array is required" }); + if (!fs.existsSync(CHAT_HISTORY_DIR)) fs.mkdirSync(CHAT_HISTORY_DIR, { recursive: true }); + fs.writeFileSync(getChatHistoryPath(userId), JSON.stringify({ messages }, null, 2)); + return res.status(200).json({ ok: true }); + } catch (err) { + return res.status(500).json({ error: "Failed to save chat history", details: String(err) }); + } +}); + +router.delete("/chat-history", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const filePath = getChatHistoryPath(userId); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + return res.status(200).json({ ok: true }); + } catch (err) { + return res.status(500).json({ error: "Failed to clear chat history", details: String(err) }); + } +}); + export default router; diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index b1c78039..762dedbd 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -128,6 +128,28 @@ function loadSavedMessages(): Message[] { return [makeMsg("bot", "Hi! What can I help you with today?")]; } +const WELCOME = [makeMsg("bot", "Hi! What can I help you with today?")]; + +let saveTimer: ReturnType | null = null; +function saveChatHistoryToServer(msgs: Message[]) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + const saveable = msgs.filter((m) => !m.isLoading); + fetch("/api/ai/chat-history", { + method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }, + body: JSON.stringify({ messages: saveable }), + }).catch(() => {}); + }, 500); +} + +function clearChatHistoryOnServer() { + fetch("/api/ai/chat-history", { + method: "DELETE", + headers: { Authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }, + }).catch(() => {}); +} + export function ChatbotButton() { const [open, setOpen] = useState(false); const [step, setStep] = useState("menu"); @@ -173,15 +195,36 @@ export function ChatbotButton() { const freeTextRef = useRef(null); const fileInputRef = useRef(null); + // Load chat history from server on first mount (server is source of truth) + const serverLoaded = useRef(false); + useEffect(() => { + if (serverLoaded.current) return; + serverLoaded.current = true; + const token = localStorage.getItem("token"); + if (!token) return; + fetch("/api/ai/chat-history", { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then((data) => { + if (Array.isArray(data.messages) && data.messages.length > 0) { + setMessages(data.messages); + try { sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(data.messages)); } catch {} + } + }) + .catch(() => {}); + }, []); + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, step]); - // Persist messages across navigation (cleared on logout) + // Persist messages to sessionStorage AND server useEffect(() => { try { const saveable = messages.filter((m) => !m.isLoading); sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable)); + saveChatHistoryToServer(messages); } catch {} }, [messages]); @@ -230,6 +273,7 @@ export function ChatbotButton() { try { sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh)); } catch {} + clearChatHistoryOnServer(); setMessages(fresh); };