Files
maternal-app/maternal-web/components/features/ai-chat/AIChatInterface.tsx
Andrei 9fab99da1d
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
feat: Add AI chat conversation history UI
Implemented full conversation management interface with the following features:
- Conversation history sidebar (desktop) / drawer (mobile)
- Load and display all user conversations
- Click to load specific conversation
- "New Chat" button to start fresh conversation
- Delete conversation with confirmation dialog
- Persist conversationId across messages in same conversation
- Responsive design with Material-UI breakpoints

Technical Details:
- Added Conversation interface and state management (lines 107-111)
- Load conversations from GET /api/v1/ai/conversations on mount
- Load specific conversation from GET /api/v1/ai/conversations/:id
- Delete conversation via DELETE /api/v1/ai/conversations/:id
- Updated handleSend() to pass currentConversationId instead of null
- Auto-update conversationId from API response for new conversations
- Mobile: Hamburger menu to open drawer
- Desktop: Fixed 320px sidebar with conversation list

Component grew from 420 → 663 lines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 22:19:06 +00:00

663 lines
20 KiB
TypeScript

'use client';
import { useState, useRef, useEffect } from 'react';
import {
Box,
TextField,
IconButton,
Paper,
Typography,
Avatar,
CircularProgress,
Chip,
Drawer,
List,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
Divider,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Send,
SmartToy,
Person,
AutoAwesome,
Chat,
Delete,
Add,
Menu as MenuIcon,
Close,
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/lib/auth/AuthContext';
import apiClient from '@/lib/api/client';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
interface Conversation {
id: string;
title: string;
messages: Array<{
role: string;
content: string;
timestamp: string;
}>;
totalTokens: number;
createdAt: string;
updatedAt: string;
}
const suggestedQuestions = [
'How much should my baby sleep at 3 months?',
'What are normal feeding patterns?',
'When should I introduce solid foods?',
'Tips for better sleep routine',
];
const thinkingMessages = [
'Gathering baby wisdom...',
'Consulting the baby books...',
'Mixing up the perfect answer...',
'Warming up some advice...',
'Preparing your bottle of knowledge...',
'Counting tiny fingers and toes...',
'Connecting the building blocks...',
'Peeking into the toy box...',
'Arranging the puzzle pieces...',
'Stirring the baby food jar...',
'Polishing the pacifier of wisdom...',
'Tiptoeing through naptime...',
'Organizing the diaper bag...',
'Wrapping up your answer with love...',
'Brewing a warm cup of guidance...',
'Knitting together some thoughts...',
'Tucking in the details...',
'Sprinkling some magic dust...',
'Humming a lullaby while I think...',
];
// Get a random selection of 3-5 thinking messages
const getRandomThinkingMessages = () => {
const count = Math.floor(Math.random() * 3) + 3; // 3 to 5
const shuffled = [...thinkingMessages].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
};
export const AIChatInterface: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [currentThinkingMessages, setCurrentThinkingMessages] = useState<string[]>([]);
const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const thinkingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const { user } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load conversations on mount
useEffect(() => {
loadConversations();
}, []);
// Cycle through thinking messages while loading
useEffect(() => {
if (isLoading) {
const randomMessages = getRandomThinkingMessages();
setCurrentThinkingMessages(randomMessages);
setCurrentThinkingIndex(0);
thinkingIntervalRef.current = setInterval(() => {
setCurrentThinkingIndex((prev) => (prev + 1) % randomMessages.length);
}, 2000); // Change message every 2 seconds
} else {
if (thinkingIntervalRef.current) {
clearInterval(thinkingIntervalRef.current);
thinkingIntervalRef.current = null;
}
setCurrentThinkingMessages([]);
setCurrentThinkingIndex(0);
}
return () => {
if (thinkingIntervalRef.current) {
clearInterval(thinkingIntervalRef.current);
}
};
}, [isLoading]);
const loadConversations = async () => {
try {
const response = await apiClient.get('/api/v1/ai/conversations');
setConversations(response.data.data.conversations);
} catch (error) {
console.error('Failed to load conversations:', error);
}
};
const loadConversation = async (conversationId: string) => {
try {
const response = await apiClient.get(`/api/v1/ai/conversations/${conversationId}`);
const conversation = response.data.data.conversation;
// Convert conversation messages to Message format
const loadedMessages: Message[] = conversation.messages.map((msg: any, index: number) => ({
id: `${conversationId}-${index}`,
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content,
timestamp: new Date(msg.timestamp),
}));
setMessages(loadedMessages);
setCurrentConversationId(conversationId);
if (isMobile) {
setDrawerOpen(false);
}
} catch (error) {
console.error('Failed to load conversation:', error);
}
};
const handleNewConversation = () => {
setMessages([]);
setCurrentConversationId(null);
if (isMobile) {
setDrawerOpen(false);
}
};
const handleDeleteConversation = async () => {
if (!conversationToDelete) return;
try {
await apiClient.delete(`/api/v1/ai/conversations/${conversationToDelete}`);
// If deleting current conversation, start new one
if (conversationToDelete === currentConversationId) {
handleNewConversation();
}
// Reload conversations list
await loadConversations();
setDeleteDialogOpen(false);
setConversationToDelete(null);
} catch (error) {
console.error('Failed to delete conversation:', error);
}
};
const handleSend = async (message?: string) => {
const messageText = message || input.trim();
if (!messageText || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await apiClient.post('/api/v1/ai/chat', {
message: messageText,
conversationId: currentConversationId,
});
const responseData = response.data.data;
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: responseData.message,
timestamp: new Date(responseData.timestamp),
};
setMessages((prev) => [...prev, assistantMessage]);
// Update current conversation ID if it's a new conversation
if (!currentConversationId && responseData.conversationId) {
setCurrentConversationId(responseData.conversationId);
}
// Reload conversations to update the list
await loadConversations();
} catch (error) {
console.error('AI chat error:', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: 'Sorry, I encountered an error. Please try again.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleSuggestedQuestion = (question: string) => {
handleSend(question);
};
const drawerContent = (
<Box sx={{ width: { xs: 280, md: 320 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" fontWeight="600">
Chat History
</Typography>
{isMobile && (
<IconButton onClick={() => setDrawerOpen(false)} size="small">
<Close />
</IconButton>
)}
</Box>
<Button
fullWidth
variant="contained"
startIcon={<Add />}
onClick={handleNewConversation}
sx={{ borderRadius: 2 }}
>
New Chat
</Button>
</Box>
<List sx={{ flex: 1, overflow: 'auto', py: 1 }}>
{conversations.length === 0 ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Chat sx={{ fontSize: 48, color: 'text.secondary', opacity: 0.3, mb: 1 }} />
<Typography variant="body2" color="text.secondary">
No conversations yet
</Typography>
</Box>
) : (
conversations.map((conversation) => (
<ListItem key={conversation.id} disablePadding>
<ListItemButton
selected={conversation.id === currentConversationId}
onClick={() => loadConversation(conversation.id)}
sx={{
mx: 1,
borderRadius: 2,
mb: 0.5,
'&.Mui-selected': {
bgcolor: 'primary.light',
'&:hover': {
bgcolor: 'primary.light',
},
},
}}
>
<ListItemIcon>
<Chat />
</ListItemIcon>
<ListItemText
primary={conversation.title}
secondary={new Date(conversation.createdAt).toLocaleDateString()}
primaryTypographyProps={{
noWrap: true,
fontWeight: conversation.id === currentConversationId ? 600 : 400,
}}
secondaryTypographyProps={{
variant: 'caption',
}}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setConversationToDelete(conversation.id);
setDeleteDialogOpen(true);
}}
sx={{ ml: 1 }}
>
<Delete fontSize="small" />
</IconButton>
</ListItemButton>
</ListItem>
))
)}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex', height: 'calc(100vh - 200px)', gap: 2 }}>
{/* Conversation History Sidebar - Desktop */}
{!isMobile && (
<Paper elevation={0} sx={{ width: 320, borderRadius: 2, overflow: 'hidden' }}>
{drawerContent}
</Paper>
)}
{/* Mobile Drawer */}
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
ModalProps={{
keepMounted: true, // Better mobile performance
}}
>
{drawerContent}
</Drawer>
{/* Chat Area */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
borderRadius: 2,
overflow: 'hidden',
}}
>
{/* Header */}
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 0,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isMobile && (
<IconButton onClick={() => setDrawerOpen(true)} edge="start">
<MenuIcon />
</IconButton>
)}
<Avatar sx={{ bgcolor: 'primary.main' }}>
<SmartToy />
</Avatar>
<Box>
<Typography variant="h6" fontWeight="600">
AI Parenting Assistant
</Typography>
<Typography variant="caption" color="text.secondary">
Ask me anything about parenting and childcare
</Typography>
</Box>
</Box>
</Paper>
{/* Messages Container */}
<Box
sx={{
flex: 1,
overflowY: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{messages.length === 0 && (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
}}
>
<AutoAwesome sx={{ fontSize: 64, color: 'primary.main', opacity: 0.5 }} />
<Typography variant="h6" color="text.secondary" textAlign="center">
Hi {user?.name}! How can I help you today?
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1,
justifyContent: 'center',
maxWidth: 600,
}}
>
{suggestedQuestions.map((question, index) => (
<Chip
key={index}
label={question}
onClick={() => handleSuggestedQuestion(question)}
sx={{
borderRadius: 3,
'&:hover': {
bgcolor: 'primary.light',
color: 'white',
},
}}
/>
))}
</Box>
</Box>
)}
<AnimatePresence>
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Box
sx={{
display: 'flex',
gap: 2,
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
{message.role === 'assistant' && (
<Avatar sx={{ bgcolor: 'primary.main', mt: 1 }}>
<SmartToy />
</Avatar>
)}
<Paper
elevation={0}
sx={{
p: 2,
maxWidth: '70%',
borderRadius: 3,
bgcolor:
message.role === 'user'
? 'primary.main'
: 'rgba(255, 255, 255, 0.95)',
color: message.role === 'user' ? 'white' : 'text.primary',
backdropFilter: 'blur(10px)',
}}
>
{message.role === 'assistant' ? (
<Box
sx={{
'& p': { mb: 1 },
'& strong': { fontWeight: 600 },
'& ul, & ol': { pl: 2, mb: 1 },
'& li': { mb: 0.5 },
'& hr': { my: 2, borderColor: 'divider' },
'& h1, & h2, & h3, & h4, & h5, & h6': {
fontWeight: 600,
mb: 1,
mt: 1.5
},
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</Box>
) : (
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap' }}>
{message.content}
</Typography>
)}
<Typography
variant="caption"
sx={{
mt: 1,
display: 'block',
opacity: 0.7,
}}
>
{message.timestamp.toLocaleTimeString()}
</Typography>
</Paper>
{message.role === 'user' && (
<Avatar sx={{ bgcolor: 'secondary.main', mt: 1 }}>
<Person />
</Avatar>
)}
</Box>
</motion.div>
))}
</AnimatePresence>
{isLoading && (
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<SmartToy />
</Avatar>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.95)',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<CircularProgress size={20} />
<Typography
variant="body2"
color="text.secondary"
sx={{
transition: 'opacity 0.3s ease-in-out',
}}
>
{currentThinkingMessages[currentThinkingIndex] || 'Thinking...'}
</Typography>
</Paper>
</Box>
)}
<div ref={messagesEndRef} />
</Box>
{/* Input Area */}
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 0,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
borderTop: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<TextField
fullWidth
multiline
maxRows={4}
placeholder="Ask me anything..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
disabled={isLoading}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<IconButton
color="primary"
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
sx={{
width: 48,
height: 48,
bgcolor: 'primary.main',
color: 'white',
'&:hover': {
bgcolor: 'primary.dark',
},
'&:disabled': {
bgcolor: 'action.disabledBackground',
},
}}
>
<Send />
</IconButton>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
This AI assistant provides general information. Always consult healthcare professionals
for medical advice.
</Typography>
</Paper>
</Box>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete this conversation? This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDeleteConversation} color="error" variant="contained">
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};