Files
maternal-app/maternal-web/components/features/ai-chat/AIChatInterface.tsx
Andrei e4b97df0c0
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: Implement AI response feedback UI and complete high-priority features
Frontend Features:
- Add MessageFeedback component with thumbs up/down buttons
- Positive feedback submits immediately with success toast
- Negative feedback opens dialog for optional text input
- Integrate feedback buttons on all AI assistant messages
- Add success Snackbar confirmation message
- Translation keys added to ai.json (feedback section)

Backend Features:
- Add POST /api/v1/ai/feedback endpoint
- Create FeedbackDto with conversation ID validation
- Implement submitFeedback service method
- Store feedback in conversation metadata with timestamps
- Add audit logging for feedback submissions
- Fix conversationId regex validation to support nanoid format

Legal & Compliance:
- Implement complete EULA acceptance flow with modal
- Create reusable legal content components (Terms, Privacy, EULA)
- Add LegalDocumentViewer for nested modal viewing
- Cookie Consent Banner with GDPR compliance
- Legal pages with AppShell navigation
- EULA acceptance tracking in user entity

Branding Updates:
- Rebrand from "Maternal App" to "ParentFlow"
- Update all icons (72px to 512px) from high-res source
- PWA manifest updated with ParentFlow branding
- Contact email: hello@parentflow.com
- Address: Serbota 3, Bucharest, Romania

Bug Fixes:
- Fix chat endpoint validation (support nanoid conversation IDs)
- Fix EULA acceptance API call (use apiClient vs hardcoded localhost)
- Fix icon loading errors with proper PNG generation

Documentation:
- Mark 11 high-priority features as complete in REMAINING_FEATURES.md
- Update feature statistics: 73/139 complete (53%)
- All high-priority features now complete! 🎉

Files Changed:
Frontend: 21 files (components, pages, locales, icons)
Backend: 6 files (controller, service, DTOs, migrations)
Docs: 1 file (REMAINING_FEATURES.md)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-04 11:39:02 +00:00

1116 lines
36 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,
Collapse,
Menu,
MenuItem,
InputAdornment,
} from '@mui/material';
import {
Send,
SmartToy,
Person,
AutoAwesome,
Chat,
Delete,
Add,
Menu as MenuIcon,
Close,
ExpandMore,
ExpandLess,
Folder,
FolderOpen,
MoreVert,
DriveFileMove,
CreateNewFolder,
} 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';
import { useTranslation } from '@/hooks/useTranslation';
import { useStreamingChat } from '@/hooks/useStreamingChat';
import { MessageFeedback } from './MessageFeedback';
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;
metadata?: {
groupName?: string | null;
[key: string]: any;
};
}
interface ConversationGroup {
name: string;
conversations: Conversation[];
isCollapsed: boolean;
}
export const AIChatInterface: React.FC = () => {
const { t } = useTranslation('ai');
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [streamingMessage, setStreamingMessage] = useState('');
const [useStreaming, setUseStreaming] = useState(false); // Disable streaming for now - using non-streaming endpoint
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 [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number; conversationId: string } | null>(null);
const [moveToGroupDialog, setMoveToGroupDialog] = useState<{ open: boolean; conversationId: string | null }>({ open: false, conversationId: null });
const [newGroupDialog, setNewGroupDialog] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const thinkingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const { user } = useAuth();
const { streamMessage, isStreaming } = useStreamingChat();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
// Localized arrays that depend on translations
const suggestedQuestions = [
t('interface.suggestedQuestion1'),
t('interface.suggestedQuestion2'),
t('interface.suggestedQuestion3'),
t('interface.suggestedQuestion4'),
];
const thinkingMessages = [
t('interface.thinking1'),
t('interface.thinking2'),
t('interface.thinking3'),
t('interface.thinking4'),
t('interface.thinking5'),
t('interface.thinking6'),
t('interface.thinking7'),
t('interface.thinking8'),
t('interface.thinking9'),
t('interface.thinking10'),
t('interface.thinking11'),
t('interface.thinking12'),
t('interface.thinking13'),
t('interface.thinking14'),
t('interface.thinking15'),
t('interface.thinking16'),
t('interface.thinking17'),
t('interface.thinking18'),
t('interface.thinking19'),
];
// 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);
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingMessage]);
// 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);
}
};
// Group conversations by their group name
const organizeConversations = (): ConversationGroup[] => {
const groups: { [key: string]: Conversation[] } = {};
const ungroupedLabel = t('interface.ungrouped');
// Separate conversations by group
conversations.forEach((conv) => {
const groupName = conv.metadata?.groupName || ungroupedLabel;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push(conv);
});
// Convert to array and sort
const groupArray: ConversationGroup[] = Object.entries(groups)
.map(([name, convs]) => ({
name,
conversations: convs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()),
isCollapsed: collapsedGroups.has(name),
}))
.sort((a, b) => {
// Ungrouped always last
if (a.name === ungroupedLabel) return 1;
if (b.name === ungroupedLabel) return -1;
return a.name.localeCompare(b.name);
});
return groupArray;
};
const toggleGroupCollapse = (groupName: string) => {
setCollapsedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupName)) {
newSet.delete(groupName);
} else {
newSet.add(groupName);
}
return newSet;
});
};
const handleMoveToGroup = async (conversationId: string, groupName: string | null) => {
try {
await apiClient.patch(`/api/v1/ai/conversations/${conversationId}/group`, {
groupName: groupName,
});
await loadConversations();
setMoveToGroupDialog({ open: false, conversationId: null });
setContextMenu(null);
} catch (error) {
console.error('Failed to move conversation to group:', error);
}
};
const handleCreateNewGroup = async () => {
if (!newGroupName.trim()) return;
const conversationId = moveToGroupDialog.conversationId;
if (conversationId) {
await handleMoveToGroup(conversationId, newGroupName.trim());
}
setNewGroupName('');
setNewGroupDialog(false);
};
const handleContextMenu = (event: React.MouseEvent, conversationId: string) => {
event.preventDefault();
setContextMenu({
mouseX: event.clientX - 2,
mouseY: event.clientY - 4,
conversationId,
});
};
const handleCloseContextMenu = () => {
setContextMenu(null);
};
const getExistingGroups = (): string[] => {
const groups = new Set<string>();
conversations.forEach((conv) => {
if (conv.metadata?.groupName) {
groups.add(conv.metadata.groupName);
}
});
return Array.from(groups).sort();
};
const handleSend = async (message?: string) => {
const messageText = message || input.trim();
if (!messageText || isLoading || isStreaming) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
// Use streaming if enabled
if (useStreaming) {
setIsLoading(true);
setStreamingMessage('');
try {
let accumulatedMessage = '';
await streamMessage(
{
message: messageText,
conversationId: currentConversationId || undefined,
},
(chunk) => {
if (chunk.type === 'token' && chunk.content) {
accumulatedMessage += chunk.content;
setStreamingMessage(accumulatedMessage);
}
},
// On complete
() => {
// Add the complete message to messages
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: accumulatedMessage,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
setStreamingMessage('');
setIsLoading(false);
// Reload conversations
loadConversations();
},
// On error
(error) => {
console.error('Streaming error:', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: t('interface.errorMessage'),
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
setStreamingMessage('');
setIsLoading(false);
}
);
} catch (error) {
console.error('Streaming failed:', error);
setStreamingMessage('');
setIsLoading(false);
}
} else {
// Non-streaming fallback
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: t('interface.errorMessage'),
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">
{t('history.title')}
</Typography>
{isMobile && (
<IconButton onClick={() => setDrawerOpen(false)} size="small" aria-label={t('interface.closeDrawer')}>
<Close />
</IconButton>
)}
</Box>
<Button
fullWidth
variant="contained"
startIcon={<Add />}
onClick={handleNewConversation}
sx={{
borderRadius: 2,
textTransform: 'none',
bgcolor: 'primary.light',
color: 'primary.main',
'&:hover': { bgcolor: 'primary.main', color: 'white' }
}}
>
{t('chat.newChat')}
</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">
{t('history.noHistory')}
</Typography>
</Box>
) : (
organizeConversations().map((group) => (
<Box key={group.name}>
{/* Group Header */}
<ListItemButton
onClick={() => toggleGroupCollapse(group.name)}
sx={{
py: 1,
px: 2,
bgcolor: 'background.default',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{group.isCollapsed ? <Folder /> : <FolderOpen />}
</ListItemIcon>
<ListItemText
primary={group.name}
secondary={t('interface.chatCount', { count: group.conversations.length })}
primaryTypographyProps={{
variant: 'body2',
fontWeight: 600,
}}
secondaryTypographyProps={{
variant: 'caption',
}}
/>
{group.isCollapsed ? <ExpandMore fontSize="small" /> : <ExpandLess fontSize="small" />}
</ListItemButton>
{/* Conversations in Group */}
<Collapse in={!group.isCollapsed} timeout="auto" unmountOnExit>
<List disablePadding>
{group.conversations.map((conversation) => (
<ListItem key={conversation.id} disablePadding>
<ListItemButton
selected={conversation.id === currentConversationId}
onClick={() => loadConversation(conversation.id)}
onContextMenu={(e) => handleContextMenu(e, conversation.id)}
sx={{
mx: 1,
pl: 5,
borderRadius: 2,
mb: 0.5,
'&.Mui-selected': {
bgcolor: 'primary.light',
'&:hover': {
bgcolor: 'primary.light',
},
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Chat fontSize="small" />
</ListItemIcon>
<ListItemText
primary={conversation.title}
secondary={new Date(conversation.updatedAt).toLocaleDateString()}
primaryTypographyProps={{
noWrap: true,
fontSize: '0.875rem',
fontWeight: conversation.id === currentConversationId ? 600 : 400,
}}
secondaryTypographyProps={{
variant: 'caption',
}}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (isMobile) {
handleContextMenu(e, conversation.id);
} else {
setConversationToDelete(conversation.id);
setDeleteDialogOpen(true);
}
}}
sx={{ ml: 1 }}
aria-label={isMobile ? t('interface.moreOptions') : t('interface.deleteConversation')}
>
{isMobile ? <MoreVert fontSize="small" /> : <Delete fontSize="small" />}
</IconButton>
</ListItemButton>
</ListItem>
))}
</List>
</Collapse>
</Box>
))
)}
</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">
{t('interface.assistantTitle')}
</Typography>
<Typography variant="caption" color="text.secondary">
{t('interface.assistantSubtitle')}
</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">
{t('interface.greeting', { name: user?.name })}
</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={{
py: 2,
borderRadius: 2,
fontSize: '0.875rem',
bgcolor: 'background.paper',
border: 1,
borderColor: 'divider',
'&:hover': {
bgcolor: 'primary.light',
borderColor: 'primary.main',
},
}}
/>
))}
</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="caption"
sx={{
mt: 1,
display: 'block',
opacity: 0.7,
}}
>
{message.timestamp.toLocaleTimeString()}
</Typography>
<MessageFeedback
messageId={message.id}
conversationId={currentConversationId}
/>
</>
) : (
<>
<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>
{/* Streaming Message Display */}
{streamingMessage && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-start' }}>
<Avatar sx={{ bgcolor: 'primary.main', mt: 1 }}>
<SmartToy />
</Avatar>
<Paper
elevation={0}
sx={{
p: 2,
maxWidth: '70%',
borderRadius: 3,
bgcolor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
<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]}>
{streamingMessage}
</ReactMarkdown>
<Box
component="span"
sx={{
display: 'inline-block',
width: '2px',
height: '1.2em',
bgcolor: 'primary.main',
ml: 0.5,
verticalAlign: 'text-bottom',
animation: 'blink 1s infinite',
'@keyframes blink': {
'0%, 49%': { opacity: 1 },
'50%, 100%': { opacity: 0 },
},
}}
/>
</Box>
</Paper>
</Box>
</motion.div>
)}
{/* Loading Indicator (shown when waiting for first token) */}
{isLoading && !streamingMessage && (
<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] || t('chat.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={t('interface.inputPlaceholder')}
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,
bgcolor: 'background.default',
'&:hover fieldset': { borderColor: 'primary.main' },
'&.Mui-focused fieldset': { borderColor: 'primary.main' }
},
}}
/>
<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' }}>
{t('interface.disclaimerFooter')}
</Typography>
</Paper>
</Box>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>{t('interface.deleteDialogTitle')}</DialogTitle>
<DialogContent>
<Typography>
{t('interface.deleteDialogMessage')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>{t('interface.cancel')}</Button>
<Button onClick={handleDeleteConversation} color="error" variant="contained">
{t('interface.delete')}
</Button>
</DialogActions>
</Dialog>
{/* Context Menu for Conversation Actions */}
<Menu
open={contextMenu !== null}
onClose={handleCloseContextMenu}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined
}
>
<MenuItem
onClick={() => {
if (contextMenu) {
setMoveToGroupDialog({ open: true, conversationId: contextMenu.conversationId });
}
handleCloseContextMenu();
}}
>
<ListItemIcon>
<DriveFileMove fontSize="small" />
</ListItemIcon>
<ListItemText>{t('interface.moveToGroup')}</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
if (contextMenu) {
setConversationToDelete(contextMenu.conversationId);
setDeleteDialogOpen(true);
}
handleCloseContextMenu();
}}
>
<ListItemIcon>
<Delete fontSize="small" />
</ListItemIcon>
<ListItemText>{t('interface.delete')}</ListItemText>
</MenuItem>
</Menu>
{/* Move to Group Dialog */}
<Dialog
open={moveToGroupDialog.open}
onClose={() => setMoveToGroupDialog({ open: false, conversationId: null })}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t('interface.moveToGroup')}</DialogTitle>
<DialogContent>
<List>
<ListItemButton
onClick={() => {
if (moveToGroupDialog.conversationId) {
handleMoveToGroup(moveToGroupDialog.conversationId, null);
}
}}
>
<ListItemIcon>
<Chat />
</ListItemIcon>
<ListItemText primary={t('interface.ungrouped')} />
</ListItemButton>
<Divider />
{getExistingGroups().map((groupName) => (
<ListItemButton
key={groupName}
onClick={() => {
if (moveToGroupDialog.conversationId) {
handleMoveToGroup(moveToGroupDialog.conversationId, groupName);
}
}}
>
<ListItemIcon>
<Folder />
</ListItemIcon>
<ListItemText primary={groupName} />
</ListItemButton>
))}
<Divider />
<ListItemButton onClick={() => setNewGroupDialog(true)}>
<ListItemIcon>
<CreateNewFolder />
</ListItemIcon>
<ListItemText primary={t('interface.createNewGroup')} />
</ListItemButton>
</List>
</DialogContent>
<DialogActions>
<Button onClick={() => setMoveToGroupDialog({ open: false, conversationId: null })}>
{t('interface.cancel')}
</Button>
</DialogActions>
</Dialog>
{/* Create New Group Dialog */}
<Dialog
open={newGroupDialog}
onClose={() => {
setNewGroupDialog(false);
setNewGroupName('');
}}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t('interface.createNewGroup')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t('interface.groupNameLabel')}
fullWidth
variant="outlined"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleCreateNewGroup();
}
}}
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setNewGroupDialog(false);
setNewGroupName('');
}}
>
{t('interface.cancel')}
</Button>
<Button onClick={handleCreateNewGroup} variant="contained" disabled={!newGroupName.trim()}>
{t('interface.create')}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};