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
|
||||
- ✅ **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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user