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>
This commit is contained in:
@@ -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
|
- ✅ **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
|
- ✅ **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
|
- ✅ **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
|
### Key Gaps Identified
|
||||||
- **Backend**: 35 features not implemented (19 completed ✅)
|
- **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**
|
- Priority: Medium ✅ **COMPLETE**
|
||||||
- Impact: International user support achieved
|
- 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**
|
- Status: **IMPLEMENTED**
|
||||||
- Current: Comprehensive security system with 25+ regex patterns
|
- 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)
|
- Implemented: System manipulation detection, role change blocking, data exfiltration prevention, sanitizeInput() called in chat flow (ai.service.ts:193)
|
||||||
|
|||||||
@@ -10,8 +10,32 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Chip,
|
Chip,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
} from '@mui/material';
|
} 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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import apiClient from '@/lib/api/client';
|
import apiClient from '@/lib/api/client';
|
||||||
@@ -25,6 +49,19 @@ interface Message {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
messages: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}>;
|
||||||
|
totalTokens: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
const suggestedQuestions = [
|
const suggestedQuestions = [
|
||||||
'How much should my baby sleep at 3 months?',
|
'How much should my baby sleep at 3 months?',
|
||||||
'What are normal feeding patterns?',
|
'What are normal feeding patterns?',
|
||||||
@@ -67,9 +104,16 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [currentThinkingMessages, setCurrentThinkingMessages] = useState<string[]>([]);
|
const [currentThinkingMessages, setCurrentThinkingMessages] = useState<string[]>([]);
|
||||||
const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const thinkingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const thinkingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -79,6 +123,11 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
// Load conversations on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cycle through thinking messages while loading
|
// Cycle through thinking messages while loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -105,6 +154,66 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [isLoading]);
|
}, [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 handleSend = async (message?: string) => {
|
||||||
const messageText = message || input.trim();
|
const messageText = message || input.trim();
|
||||||
if (!messageText || isLoading) return;
|
if (!messageText || isLoading) return;
|
||||||
@@ -123,17 +232,26 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/api/v1/ai/chat', {
|
const response = await apiClient.post('/api/v1/ai/chat', {
|
||||||
message: messageText,
|
message: messageText,
|
||||||
conversationId: null,
|
conversationId: currentConversationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseData = response.data.data;
|
||||||
const assistantMessage: Message = {
|
const assistantMessage: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: response.data.data.message,
|
content: responseData.message,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(responseData.timestamp),
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, assistantMessage]);
|
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) {
|
} catch (error) {
|
||||||
console.error('AI chat error:', error);
|
console.error('AI chat error:', error);
|
||||||
const errorMessage: Message = {
|
const errorMessage: Message = {
|
||||||
@@ -152,10 +270,113 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
handleSend(question);
|
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 (
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
height: 'calc(100vh - 200px)',
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
background: 'linear-gradient(135deg, #FFF5F5 0%, #FFE4E1 100%)',
|
||||||
@@ -176,6 +397,11 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton onClick={() => setDrawerOpen(true)} edge="start">
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||||
<SmartToy />
|
<SmartToy />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -415,5 +641,22 @@ export const AIChatInterface: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user