Add ChatGPT-style chat sessions with sidebar history
This commit is contained in:
parent
c1057830b1
commit
7aaab29baf
2 changed files with 192 additions and 78 deletions
43
docs/chat-sessions.md
Normal file
43
docs/chat-sessions.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Chat Session Structure (AI Feature)
|
||||||
|
|
||||||
|
## Design Pattern (like ChatGPT)
|
||||||
|
- **Sidebar**: List of past chat sessions with title + timestamp
|
||||||
|
- **Main area**: Active conversation
|
||||||
|
|
||||||
|
## Data Structure
|
||||||
|
```typescript
|
||||||
|
interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string; // First question (truncated to ~30 chars)
|
||||||
|
messages: AIChat[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIChat {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
- `tia_chat_sessions`: Array of ChatSession in localStorage
|
||||||
|
- Current session ID: `tia_current_session`
|
||||||
|
|
||||||
|
## UI Pattern
|
||||||
|
- **Left sidebar (w-64)**: List of sessions with title + date, delete option
|
||||||
|
- **Right main area**: Chat interface with message bubbles
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
1. User opens /ai → loads most recent session or creates new
|
||||||
|
2. User asks question → adds to current session
|
||||||
|
3. First question becomes session title (truncated)
|
||||||
|
4. Can switch sessions via sidebar
|
||||||
|
5. Can start new conversation
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- Use React state for current session
|
||||||
|
- Save to localStorage on each message
|
||||||
|
- Auto-title from first user message
|
||||||
|
|
@ -1,105 +1,176 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface Message {
|
interface AIChat {
|
||||||
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIPage() {
|
interface ChatSession {
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
id: string;
|
||||||
{ role: "assistant", content: "Hi! I'm Tia - your baby care assistant. Ask me anything about your baby's health, feeding, sleep, or development!" },
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIChatPage() {
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
const childId = "5ad3b16a-1e0d-45ab-bc91-038397d75d0a";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
const loaded = getSessions();
|
||||||
}, [messages]);
|
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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentSession = sessions.find(s => s.id === currentSessionId);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || loading) return;
|
if (!input.trim() || loading || !currentSession) return;
|
||||||
|
|
||||||
const userMessage = input.trim();
|
|
||||||
setInput("");
|
|
||||||
setMessages((prev) => [...prev, { role: "user", content: userMessage }]);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
const userMsg: AIChat = { id: crypto.randomUUID(), role: "user", content: input.trim(), timestamp: Date.now() };
|
||||||
const res = await fetch("/api/ai", {
|
const updated: ChatSession = { ...currentSession, messages: [...currentSession.messages, userMsg], updatedAt: Date.now() };
|
||||||
method: "POST",
|
if (updated.messages.length === 1) {
|
||||||
headers: { "Content-Type": "application/json" },
|
updated.title = input.trim().slice(0, 40) + (input.trim().length > 40 ? "..." : "");
|
||||||
body: JSON.stringify({
|
|
||||||
messages: [...messages, { role: "user", content: userMessage }],
|
|
||||||
childId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: data.reply || data.error }]);
|
|
||||||
} catch (err) {
|
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong. Try again." }]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newSessions = sessions.map(s => s.id === currentSessionId ? updated : s);
|
||||||
|
const inputVal = 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);
|
||||||
|
}
|
||||||
setLoading(false);
|
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" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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 flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex">
|
||||||
{/* Header */}
|
<div className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden transition-all flex-shrink-0`}>
|
||||||
<div className="p-4 flex items-center gap-4">
|
<div className="w-64 h-full bg-white dark:bg-gray-800 flex flex-col border-r">
|
||||||
<a href="/menu" className="p-2">←</a>
|
<div className="p-4 flex justify-between items-center border-b">
|
||||||
<h1 className="text-xl font-bold">🤖 Tia AI</h1>
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-24 space-y-4">
|
<div className="p-4 flex items-center gap-4">
|
||||||
{messages.map((msg, i) => (
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">{sidebarOpen ? "◀" : "☰"}</button>
|
||||||
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
<a href="/" className="text-gray-500">← Home</a>
|
||||||
<div
|
</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>
|
|
||||||
))}
|
|
||||||
{loading && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-3 rounded-2xl animate-pulse">
|
|
||||||
Thinking...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||||
<div className="fixed bottom-0 left-0 right-0 p-4 bg-white/80 dark:bg-gray-900/80">
|
{(!currentSession || currentSession.messages.length === 0) ? (
|
||||||
<div className="flex gap-2">
|
<div className="text-center text-gray-500 mt-20">
|
||||||
<input
|
<p className="text-lg mb-2">Start a conversation</p>
|
||||||
type="text"
|
<p className="text-sm">Ask me anything about your baby!</p>
|
||||||
value={input}
|
</div>
|
||||||
onChange={(e) => setInput(e.target.value)}
|
) : currentSession.messages.map(msg => (
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
placeholder="Ask about your baby..."
|
<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>
|
||||||
className="flex-1 p-3 border rounded-full"
|
</div>
|
||||||
disabled={loading}
|
))}
|
||||||
/>
|
{loading && <div className="flex justify-start"><div className="bg-white dark:bg-gray-800 p-3 rounded-2xl animate-pulse">Thinking...</div></div>}
|
||||||
<button
|
</div>
|
||||||
onClick={handleSend}
|
|
||||||
disabled={loading || !input.trim()}
|
<div className="p-4">
|
||||||
className="p-3 bg-rose-400 text-white rounded-full disabled:opacity-50"
|
<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>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue