feat: Add AI chat conversation history UI
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

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>
This commit is contained in:
2025-10-02 22:19:06 +00:00
parent 11be6d4962
commit 9fab99da1d
2 changed files with 525 additions and 243 deletions

View File

@@ -41,6 +41,7 @@ This document identifies features specified in the documentation that are not ye
-**LangChain Context Management** (October 2, 2025): 4000 token budget with priority weighting, automatic context pruning
-**Conversation Memory** (October 2, 2025): Semantic search with embeddings, conversation summarization, memory retrieval
-**Multi-Language AI** (October 2, 2025): 5 languages (en/es/fr/pt/zh) with localized prompts and safety responses
-**AI Chat Conversation History** (October 2, 2025): Full conversation management UI with sidebar, conversation switching, deletion, and persistence
### Key Gaps Identified
- **Backend**: 35 features not implemented (19 completed ✅)
@@ -273,7 +274,45 @@ This document identifies features specified in the documentation that are not ye
- Priority: Medium ✅ **COMPLETE**
- Impact: International user support achieved
5. **Prompt Injection Protection** ✅ COMPLETED (Previously)
5. **AI Chat Conversation History UI** ✅ COMPLETED (October 2, 2025)
- Status: **IMPLEMENTED**
- Current: Full conversation management interface with sidebar, switching, and deletion
- Implemented:
* **Conversation State Management** (AIChatInterface.tsx:107-111):
- Tracks conversations list from API
- Maintains currentConversationId state
- Mobile drawer and delete dialog state
* **Load Conversations** (AIChatInterface.tsx:157-164):
- Fetches all user conversations on component mount
- Displays conversation history in sidebar/drawer
* **Load Conversation** (AIChatInterface.tsx:166-187):
- Loads specific conversation messages by ID
- Converts API format to Message[] format
- Auto-closes drawer on mobile after selection
* **New Conversation** (AIChatInterface.tsx:189-195):
- Clears current messages
- Resets conversationId to null for fresh chat
* **Delete Conversation** (AIChatInterface.tsx:197-215):
- Delete confirmation dialog
- API call to DELETE /api/v1/ai/conversations/:id
- Starts new conversation if deleting current one
- Reloads conversation list after deletion
* **Updated Chat Flow** (AIChatInterface.tsx:217-252):
- Passes currentConversationId in POST /api/v1/ai/chat
- Updates conversationId from API response for new chats
- Reloads conversation list after each message
* **Responsive UI**:
- Desktop: 320px sidebar with conversation list
- Mobile: Drawer with hamburger menu icon
- Conversation list shows title, date, delete icon
- Selected conversation highlighted
- "New Chat" button at top of sidebar
- Files: `maternal-web/components/features/ai-chat/AIChatInterface.tsx` (420 → 663 lines)
- Backend APIs Used: GET /api/v1/ai/conversations, GET /api/v1/ai/conversations/:id, DELETE /api/v1/ai/conversations/:id
- Priority: Medium ✅ **COMPLETE**
- Impact: Users can access chat history, switch conversations, and manage past conversations
6. **Prompt Injection Protection** ✅ COMPLETED (Previously)
- Status: **IMPLEMENTED**
- Current: Comprehensive security system with 25+ regex patterns
- Implemented: System manipulation detection, role change blocking, data exfiltration prevention, sanitizeInput() called in chat flow (ai.service.ts:193)

View File

@@ -10,8 +10,32 @@ import {
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 } from '@mui/icons-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';
@@ -25,6 +49,19 @@ interface Message {
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?',
@@ -67,9 +104,16 @@ export const AIChatInterface: React.FC = () => {
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' });
@@ -79,6 +123,11 @@ export const AIChatInterface: React.FC = () => {
scrollToBottom();
}, [messages]);
// Load conversations on mount
useEffect(() => {
loadConversations();
}, []);
// Cycle through thinking messages while loading
useEffect(() => {
if (isLoading) {
@@ -105,6 +154,66 @@ export const AIChatInterface: React.FC = () => {
};
}, [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;
@@ -123,17 +232,26 @@ export const AIChatInterface: React.FC = () => {
try {
const response = await apiClient.post('/api/v1/ai/chat', {
message: messageText,
conversationId: null,
conversationId: currentConversationId,
});
const responseData = response.data.data;
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: response.data.data.message,
timestamp: new Date(),
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 = {
@@ -152,268 +270,393 @@ export const AIChatInterface: React.FC = () => {
handleSend(question);
};
return (
<Box
sx={{
height: 'calc(100vh - 200px)',
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 }}>
<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
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>
</Box>
</Paper>
) : (
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>
);
{/* Messages Container */}
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,
overflowY: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
borderRadius: 2,
overflow: 'hidden',
}}
>
{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 }}>
{/* 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>
<Paper
elevation={0}
<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={{
p: 2,
borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.95)',
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
justifyContent: 'center',
gap: 3,
}}
>
<CircularProgress size={20} />
<Typography
variant="body2"
color="text.secondary"
<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={{
transition: 'opacity 0.3s ease-in-out',
display: 'flex',
flexWrap: 'wrap',
gap: 1,
justifyContent: 'center',
maxWidth: 600,
}}
>
{currentThinkingMessages[currentThinkingIndex] || 'Thinking...'}
</Typography>
</Paper>
</Box>
)}
{suggestedQuestions.map((question, index) => (
<Chip
key={index}
label={question}
onClick={() => handleSuggestedQuestion(question)}
sx={{
borderRadius: 3,
'&:hover': {
bgcolor: 'primary.light',
color: 'white',
},
}}
/>
))}
</Box>
</Box>
)}
<div ref={messagesEndRef} />
<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>
{/* 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>
{/* 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>
);
};