feat: persist AI chat history to server-side JSON file
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
||||||
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
|
import { runInternalChatWorkflow, createAppointmentToday } from "../ai/internal-chat-workflow";
|
||||||
import { resolveAiProvider } from "../ai/llm-factory";
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
// GET /api/ai/settings
|
// 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<any> => {
|
||||||
|
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<any> => {
|
||||||
|
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<any> => {
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -128,6 +128,28 @@ function loadSavedMessages(): Message[] {
|
|||||||
return [makeMsg("bot", "Hi! What can I help you with today?")];
|
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<typeof setTimeout> | 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() {
|
export function ChatbotButton() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [step, setStep] = useState<Step>("menu");
|
const [step, setStep] = useState<Step>("menu");
|
||||||
@@ -173,15 +195,36 @@ export function ChatbotButton() {
|
|||||||
const freeTextRef = useRef<HTMLTextAreaElement>(null);
|
const freeTextRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(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(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages, step]);
|
}, [messages, step]);
|
||||||
|
|
||||||
// Persist messages across navigation (cleared on logout)
|
// Persist messages to sessionStorage AND server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const saveable = messages.filter((m) => !m.isLoading);
|
const saveable = messages.filter((m) => !m.isLoading);
|
||||||
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(saveable));
|
||||||
|
saveChatHistoryToServer(messages);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@@ -230,6 +273,7 @@ export function ChatbotButton() {
|
|||||||
try {
|
try {
|
||||||
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
|
sessionStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(fresh));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
clearChatHistoryOnServer();
|
||||||
setMessages(fresh);
|
setMessages(fresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user