- Add dark mode support throughout - Add consistent header with back button - Add modal dark mode styling Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
255 lines
No EOL
9 KiB
TypeScript
255 lines
No EOL
9 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useFamily } from "../FamilyProvider";
|
||
|
||
interface AIChat {
|
||
id: string;
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface ChatSession {
|
||
id: string;
|
||
title: string;
|
||
messages: AIChat[];
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export default function AIChatPage() {
|
||
const { childId } = useFamily();
|
||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||
const [input, setInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
const [error, setError] = useState<string>("");
|
||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
console.log("AI page: childId from useFamily:", childId);
|
||
if (childId) {
|
||
fetchSessions();
|
||
}
|
||
}, [childId]);
|
||
|
||
const fetchSessions = async () => {
|
||
if (!childId) return;
|
||
try {
|
||
const res = await fetch(`/api/chat?childId=${childId}`);
|
||
const data = await res.json();
|
||
if (data.error) {
|
||
setError(data.error);
|
||
console.error("API error:", data.error);
|
||
return;
|
||
}
|
||
setSessions(data.sessions || []);
|
||
setError("");
|
||
} 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) return;
|
||
setLoading(true);
|
||
|
||
const userContent = input.trim();
|
||
setInput("");
|
||
|
||
// Show user message immediately in UI
|
||
const tempUserMsg = { id: "temp-" + Date.now(), role: "user" as const, content: userContent, createdAt: new Date().toISOString() };
|
||
if (currentSessionId) {
|
||
const sessionIdx = sessions.findIndex(s => s.id === currentSessionId);
|
||
if (sessionIdx >= 0) {
|
||
const updatedSessions = [...sessions];
|
||
updatedSessions[sessionIdx] = { ...updatedSessions[sessionIdx], messages: [...(updatedSessions[sessionIdx].messages || []), tempUserMsg] };
|
||
setSessions(updatedSessions);
|
||
}
|
||
}
|
||
|
||
// Auto-create session if none selected
|
||
let sessionId = currentSessionId;
|
||
if (!sessionId && childId) {
|
||
try {
|
||
const res = await fetch("/api/chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ childId, title: userContent.slice(0, 30) }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.session) {
|
||
sessionId = data.session.id;
|
||
const newSession = { ...data.session, messages: [tempUserMsg] };
|
||
setSessions([newSession, ...sessions]);
|
||
setCurrentSessionId(sessionId);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to create session:", err);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!sessionId) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Save user message
|
||
await fetch("/api/chat", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ sessionId, role: "user", content: userContent }),
|
||
});
|
||
|
||
// Get AI response
|
||
const aiRes = await fetch("/api/ai", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ messages: [{ role: "user", content: userContent }] }),
|
||
});
|
||
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, role: "assistant", content: aiData.reply }),
|
||
});
|
||
}
|
||
|
||
// Refresh sessions
|
||
fetchSessions();
|
||
} catch (err) {
|
||
console.error("Failed to send:", err);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const deleteSession = async (id: string) => {
|
||
try {
|
||
await fetch(`/api/chat?id=${id}`, { method: "DELETE" });
|
||
setSessions(sessions.filter(s => s.id !== id));
|
||
setDeleteConfirm(null);
|
||
} catch (err) {
|
||
console.error("Failed to delete:", err);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||
{error && (
|
||
<div className="p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200">
|
||
Error: {error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header - same pattern as other pages */}
|
||
<div className="p-4 flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<a href="/menu" className="p-2 dark:text-white">←</a>
|
||
<h1 className="text-xl font-bold dark:text-white">AI Chat</h1>
|
||
</div>
|
||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 bg-white dark:bg-gray-800 rounded-lg dark:text-white">
|
||
☰
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden transition-all`}>
|
||
<div className="w-64 p-4 border-r bg-white dark:bg-gray-800 h-full overflow-y-auto">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h1 className="font-bold dark:text-white">Chats</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 flex justify-between items-center dark:text-gray-100 ${session.id === currentSessionId ? "bg-rose-100 dark:bg-rose-900" : ""}`}
|
||
>
|
||
<div className="flex-1">
|
||
<div className="text-sm font-medium truncate">{session.title}</div>
|
||
<div className="text-xs text-gray-400">{new Date(session.updatedAt).toLocaleDateString()}</div>
|
||
</div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
||
className="text-red-400 text-xs p-1"
|
||
>
|
||
🗑️
|
||
</button>
|
||
{deleteConfirm === session.id && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg">
|
||
<p className="mb-4 dark:text-white">Delete this conversation?</p>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => deleteSession(session.id)} className="bg-red-500 text-white px-3 py-1 rounded">Delete</button>
|
||
<button onClick={() => setDeleteConfirm(null)} className="bg-gray-200 dark:bg-gray-600 px-3 py-1 rounded">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main chat */}
|
||
<div className="flex-1 flex flex-col">
|
||
<div className="p-4 flex items-center border-b bg-white dark:bg-gray-800">
|
||
<span className="ml-2 font-medium dark:text-white">{currentSession?.title || "AI Chat"}</span>
|
||
</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 dark:bg-rose-900" : "mr-auto bg-gray-100 dark:bg-gray-800 dark:text-gray-100"} p-3 rounded-lg max-w-[80%] whitespace-pre-wrap`}>
|
||
{msg.content}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="p-4 border-t bg-white dark:bg-gray-800">
|
||
<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 dark:bg-gray-700 dark:text-white"
|
||
disabled={loading}
|
||
/>
|
||
<button onClick={handleSend} disabled={loading || !input.trim()} className={`px-4 py-2 text-white rounded-lg ${loading || !input.trim() ? "bg-gray-300 cursor-not-allowed" : "bg-rose-400"}`}>
|
||
{loading ? "..." : "Send"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |