Migrate Chat Sessions to database

- Add chat_sessions and chat_messages tables
- Create /api/chat endpoint for CRUD operations
- Update home page and /ai page to use database
- All chat history now persists across sessions and devices

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-10 21:13:21 +05:30
parent 3e66b259f2
commit c3255e82da
3 changed files with 266 additions and 158 deletions

View file

@ -6,42 +6,19 @@ interface AIChat {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
createdAt: string;
}
interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: number;
updatedAt: number;
}
const CHATS_KEY = "tia_chat_sessions";
function getSessions(): ChatSession[] {
if (typeof window === "undefined") return [];
try {
const data = localStorage.getItem(CHATS_KEY);
return data ? JSON.parse(data) : [];
} catch { return []; }
}
function saveSessions(sessions: ChatSession[]) {
localStorage.setItem(CHATS_KEY, JSON.stringify(sessions));
}
function createNewSession(): ChatSession {
return {
id: crypto.randomUUID(),
title: "New conversation",
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
createdAt: string;
updatedAt: string;
}
export default function AIChatPage() {
const childId = "default";
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string>("");
const [input, setInput] = useState("");
@ -49,127 +26,139 @@ export default function AIChatPage() {
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => {
const loaded = getSessions();
if (loaded.length === 0 || !currentSessionId) {
const newS = createNewSession();
loaded.unshift(newS);
saveSessions(loaded);
setCurrentSessionId(newS.id);
}
setSessions(loaded);
if (!currentSessionId && loaded.length > 0) {
setCurrentSessionId(loaded[0].id);
}
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const res = await fetch(`/api/chat?childId=${childId}`);
const data = await res.json();
setSessions(data.sessions || []);
} catch (err) {
console.error("Failed to fetch:", err);
}
};
const createNewSession = async () => {
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId, title: "New conversation" }),
});
const data = await res.json();
if (data.session) {
setSessions([data.session, ...sessions]);
setCurrentSessionId(data.session.id);
}
} catch (err) {
console.error("Failed to create:", err);
}
};
const currentSession = sessions.find(s => s.id === currentSessionId);
const handleSend = async () => {
if (!input.trim() || loading || !currentSession) return;
setLoading(true);
const userMsg: AIChat = { id: crypto.randomUUID(), role: "user", content: input.trim(), timestamp: Date.now() };
const updated: ChatSession = { ...currentSession, messages: [...currentSession.messages, userMsg], updatedAt: Date.now() };
if (updated.messages.length === 1) {
updated.title = input.trim().slice(0, 40) + (input.trim().length > 40 ? "..." : "");
}
const newSessions = sessions.map(s => s.id === currentSessionId ? updated : s);
const inputVal = input.trim();
const userContent = input.trim();
setInput("");
const tempId = currentSessionId;
try {
const res = await fetch("/api/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: inputVal }] }) });
const data = await res.json();
const assistantMsg: AIChat = { id: crypto.randomUUID(), role: "assistant", content: data.reply || "Sorry, I couldn't help with that.", timestamp: Date.now() };
const final = { ...updated, messages: [...updated.messages, assistantMsg], updatedAt: Date.now() };
const finalSessions = newSessions.map(s => s.id === tempId ? final : s);
saveSessions(finalSessions);
setSessions(finalSessions);
} catch {
const errMsg: AIChat = { id: crypto.randomUUID(), role: "assistant", content: "Something went wrong. Try again.", timestamp: Date.now() };
const errS = { ...updated, messages: [...updated.messages, errMsg], updatedAt: Date.now() };
const errSessions = newSessions.map(s => s.id === tempId ? errS : s);
saveSessions(errSessions);
setSessions(errSessions);
// Save user message
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: currentSessionId, role: "user", content: userContent }),
});
// Get AI response
const aiRes = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: currentSession.messages.concat({ id: "", role: "user", content: userContent, createdAt: "" }) }),
});
const aiData = await aiRes.json();
// Save AI response
if (aiData.reply) {
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: currentSessionId, role: "assistant", content: aiData.reply }),
});
}
// Refresh sessions
fetchSessions();
} catch (err) {
console.error("Failed to send:", err);
}
setLoading(false);
};
const switchSession = (id: string) => setCurrentSessionId(id);
const newChat = () => {
const ns = createNewSession();
const upd = [ns, ...sessions];
saveSessions(upd);
setSessions(upd);
setCurrentSessionId(ns.id);
};
const deleteSession = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
const filtered = sessions.filter(s => s.id !== id);
saveSessions(filtered);
setSessions(filtered);
if (id === currentSessionId && filtered.length > 0) setCurrentSessionId(filtered[0].id);
else if (filtered.length === 0) newChat();
};
const formatDate = (ts: number) => {
const d = new Date(ts);
const now = new Date();
return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : d.toLocaleDateString([], { month: "short", day: "numeric" });
const deleteSession = async (id: string) => {
try {
await fetch(`/api/chat?id=${id}`, { method: "DELETE" });
setSessions(sessions.filter(s => s.id !== id));
} catch (err) {
console.error("Failed to delete:", err);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex">
<div className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden transition-all flex-shrink-0`}>
<div className="w-64 h-full bg-white dark:bg-gray-800 flex flex-col border-r">
<div className="p-4 flex justify-between items-center border-b">
<h1 className="font-bold">Chats</h1>
<button onClick={newChat} className="text-2xl">+</button>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.map(s => (
<div key={s.id} onClick={() => switchSession(s.id)} className={`p-3 border-b cursor-pointer hover:bg-gray-50 ${s.id === currentSessionId ? "bg-rose-50" : ""}`}>
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{s.title}</div>
<div className="text-xs text-gray-500">{formatDate(s.createdAt)}</div>
</div>
<button onClick={(e) => deleteSession(s.id, e)} className="text-gray-400 hover:text-red-500 ml-2"></button>
<div className="flex h-screen bg-gradient-to-br from-rose-50 to-amber-50">
{/* Sidebar */}
<div className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden transition-all`}>
<div className="w-64 p-4 border-r bg-white h-full overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h1 className="font-bold">AI Chat</h1>
<button onClick={createNewSession} className="text-rose-400">+</button>
</div>
<div className="space-y-2">
{sessions.map(session => (
<div
key={session.id}
onClick={() => setCurrentSessionId(session.id)}
className={`p-2 rounded-lg cursor-pointer ${session.id === currentSessionId ? "bg-rose-100" : ""}`}
>
<div className="text-sm font-medium truncate">{session.title}</div>
<div className="text-xs text-gray-400">{new Date(session.updatedAt).toLocaleDateString()}</div>
</div>
))}
</div>
</div>
</div>
{/* Main chat */}
<div className="flex-1 flex flex-col">
<div className="p-4 flex items-center gap-4">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">{sidebarOpen ? "◀" : "☰"}</button>
<a href="/" className="text-gray-500"> Home</a>
<div className="p-4 flex items-center border-b bg-white">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2"></button>
<span className="ml-2 font-medium">{currentSession?.title || "AI Chat"}</span>
</div>
<div className="flex-1 overflow-y-auto px-4 space-y-4">
{(!currentSession || currentSession.messages.length === 0) ? (
<div className="text-center text-gray-500 mt-20">
<p className="text-lg mb-2">Start a conversation</p>
<p className="text-sm">Ask me anything about your baby!</p>
</div>
) : currentSession.messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] p-3 rounded-2xl ${msg.role === "user" ? "bg-rose-400 text-white" : "bg-white dark:bg-gray-800"}`}>{msg.content}</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{currentSession?.messages.map((msg, i) => (
<div key={i} className={`${msg.role === "user" ? "ml-auto bg-rose-100" : "mr-auto bg-gray-100"} p-3 rounded-lg max-w-[80%] whitespace-pre-wrap`}>
{msg.content}
</div>
))}
{loading && <div className="flex justify-start"><div className="bg-white dark:bg-gray-800 p-3 rounded-2xl animate-pulse">Thinking...</div></div>}
</div>
<div className="p-4">
<div className="flex gap-2 max-w-3xl mx-auto">
<input type="text" value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === "Enter" && handleSend()} placeholder="Ask anything..." className="flex-1 p-3 border rounded-full" disabled={loading} />
<button onClick={handleSend} disabled={loading || !input.trim()} className="p-3 bg-rose-400 text-white rounded-full disabled:opacity-50"></button>
<div className="p-4 border-t bg-white">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Ask about your baby..."
className="flex-1 p-3 border rounded-lg"
disabled={loading}
/>
<button onClick={handleSend} disabled={loading} className="px-4 py-2 bg-rose-400 text-white rounded-lg">
{loading ? "..." : "Send"}
</button>
</div>
</div>
</div>

104
src/app/api/chat/route.ts Normal file
View file

@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
// GET - list chat sessions for a child
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId") || "default";
try {
const sessions = await sql.unsafe(
`SELECT cs.id, cs.title, cs.created_at as "createdAt", cs.updated_at as "updatedAt",
COALESCE(
(SELECT json_agg(json_build_object('id', cm.id, 'role', cm.role, 'content', cm.content, 'createdAt', cm.created_at))
FROM chat_messages cm WHERE cm.session_id = cs.id ORDER BY cm.created_at),
'[]'::json
) as messages
FROM chat_sessions cs
WHERE cs.child_id = $1
ORDER BY cs.updated_at DESC`,
[childId]
);
return NextResponse.json({ sessions: sessions || [] });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// POST - create new chat session
export async function POST(request: Request) {
try {
const body = await request.json();
const { childId, title } = body;
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const [session] = await sql.unsafe(
`INSERT INTO chat_sessions (child_id, title)
VALUES ($1, $2)
RETURNING id, title, created_at as "createdAt", updated_at as "updatedAt"`,
[childId, title || "New conversation"]
);
return NextResponse.json({ success: true, session });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// PATCH - update session title or add message
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { sessionId, title, role, content } = body;
if (!sessionId) {
return NextResponse.json({ error: "sessionId required" }, { status: 400 });
}
if (title) {
await sql.unsafe(
`UPDATE chat_sessions SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, sessionId]
);
}
if (role && content) {
await sql.unsafe(
`INSERT INTO chat_messages (session_id, role, content) VALUES ($1, $2, $3)`,
[sessionId, role, content]
);
await sql.unsafe(
`UPDATE chat_sessions SET updated_at = NOW() WHERE id = $1`,
[sessionId]
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE - delete chat session
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID required" }, { status: 400 });
}
try {
await sql.unsafe(`DELETE FROM chat_sessions WHERE id = $1`, [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -5,21 +5,20 @@ import Link from "next/link";
import { useTheme } from "./ThemeProvider";
const OFFLINE_QUEUE_KEY = "tia_offline_queue";
const CHATS_KEY = "tia_chat_sessions";
interface AIChat {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
createdAt: string;
}
interface ChatSession {
id: string;
title: string;
messages: AIChat[];
createdAt: number;
updatedAt: number;
createdAt: string;
updatedAt: string;
}
export interface OfflineEntry {
@ -55,21 +54,25 @@ export async function processOfflineQueue() {
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(failed));
}
// --- AI Session Functions (shared with /ai page) ---
function getSessions(): ChatSession[] {
if (typeof window === "undefined") return [];
// --- AI Session Functions (using database) ---
async function getSessions(cid: string): Promise<ChatSession[]> {
try {
const data = localStorage.getItem(CHATS_KEY);
return data ? JSON.parse(data) : [];
const res = await fetch(`/api/chat?childId=${cid}`);
const data = await res.json();
return data.sessions || [];
} catch { return []; }
}
function saveSessions(sessions: ChatSession[]) {
localStorage.setItem(CHATS_KEY, JSON.stringify(sessions));
}
function createNewSession(): ChatSession {
return { id: crypto.randomUUID(), title: "New conversation", messages: [], createdAt: Date.now(), updatedAt: Date.now() };
async function createSession(cid: string): Promise<ChatSession | null> {
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId: cid, title: "New conversation" }),
});
const data = await res.json();
return data.session || null;
} catch { return null; }
}
function LogModal({ type, childId, onClose }: { type: "feed" | "diaper" | "sleep" | null; childId: string; onClose: () => void }) {
@ -139,10 +142,11 @@ export default function HomePage() {
// Load latest session on mount
useEffect(() => {
const sessions = getSessions();
getSessions(childId).then(sessions => {
if (sessions.length > 0) {
setAiChats(sessions[0].messages);
}
});
}, []);
useEffect(() => {
@ -166,47 +170,58 @@ export default function HomePage() {
toggleTheme();
};
// Unified AI chat that saves to sessions
// Unified AI chat that saves to sessions (database)
const handleAiChat = async (question?: string) => {
const q = question || aiInput;
if (!q.trim() || aiLoading) return;
setAiLoading(true);
setAiOpen(true);
const sessions = getSessions();
let currentSession = sessions[0];
const sessions = await getSessions(childId);
let currentSession: ChatSession | null = sessions[0] || null;
// Create new session if none exists
if (!currentSession) {
currentSession = createNewSession();
currentSession = await createSession(childId);
if (currentSession) {
sessions.unshift(currentSession);
}
}
const userMsg: AIChat = { id: crypto.randomUUID(), role: "user", content: q, timestamp: Date.now() };
const updatedSession: ChatSession = {
...currentSession,
messages: [...currentSession.messages, userMsg],
title: currentSession.messages.length === 0 ? q.slice(0, 40) + (q.length > 40 ? "..." : "") : currentSession.title,
updatedAt: Date.now()
};
if (!currentSession) {
setAiLoading(false);
return;
}
const userMsg: AIChat = { id: crypto.randomUUID(), role: "user", content: q, createdAt: new Date().toISOString() };
const inputVal = q.trim();
if (!question) setAiInput("");
try {
// Save user message to DB
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: currentSession.id, role: "user", content: inputVal }),
});
// Get AI response
const res = await fetch("/api/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: inputVal }] }) });
const data = await res.json();
const assistantMsg: AIChat = { id: crypto.randomUUID(), role: "assistant", content: data.reply || "Sorry, I couldn't help with that.", timestamp: Date.now() };
const finalSession = { ...updatedSession, messages: [...updatedSession.messages, assistantMsg], updatedAt: Date.now() };
const finalSessions = [finalSession, ...sessions.filter(s => s.id !== currentSession!.id)];
saveSessions(finalSessions);
setAiChats(finalSession.messages);
} catch {
const errMsg: AIChat = { id: crypto.randomUUID(), role: "assistant", content: "Something went wrong. Try again.", timestamp: Date.now() };
const errSession = { ...updatedSession, messages: [...updatedSession.messages, errMsg], updatedAt: Date.now() };
const errSessions = [errSession, ...sessions.filter(s => s.id !== currentSession!.id)];
saveSessions(errSessions);
setAiChats(errSession.messages);
const reply = data.reply || "Sorry, I couldn't help with that.";
// Save AI response to DB
await fetch("/api/chat", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: currentSession.id, role: "assistant", content: reply }),
});
// Refresh sessions
const updatedSessions = await getSessions(childId);
setAiChats(updatedSessions[0]?.messages || []);
} catch (err) {
console.error("AI chat error:", err);
}
setAiLoading(false);
};