- **EULA Persistence Fix**: Fixed EULA dialog showing on every login
- Added eulaAcceptedAt/eulaVersion to AuthResponse interface
- Updated login/register/getUserById endpoints to return EULA fields
- Changed EULACheck to use refreshUser() instead of window.reload()
- **Touch Target Accessibility**: All interactive elements now meet 48x48px minimum
- Fixed 14 undersized IconButtons across 5 files
- Changed size="small" to size="medium" with minWidth/minHeight constraints
- Updated children page, AI chat, analytics cards, legal viewer
- **Alt Text for Images**: Complete image accessibility for screen readers
- Added photoAlt field to children table (Migration V009)
- PhotoUpload component now includes alt text input field
- All Avatar components have meaningful alt text
- Default alt text: "Photo of {childName}", "{userName}'s profile photo"
- **Medical Tracking Consolidation**: Unified medical page with tabs
- Medicine page now has 3 tabs: Medication, Temperature, Doctor Visit
- Backward compatibility for legacy 'medicine' activity type
- Created dedicated /track/growth page for physical measurements
- **Track Page Updates**:
- Simplified to 6 options: Feeding, Sleep, Diaper, Medical, Activity, Growth
- Fixed grid layout to 3 cards per row with minWidth: 200px
- Updated terminology from "Medicine" to "Medical"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
1147 lines
37 KiB
TypeScript
1147 lines
37 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';
|
|
import { SuggestedQuestions } from './SuggestedQuestions';
|
|
import { generateFollowUpQuestions } from '@/lib/ai/suggestedQuestions';
|
|
|
|
interface Message {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
timestamp: Date;
|
|
suggestedQuestions?: string[];
|
|
}
|
|
|
|
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
|
|
() => {
|
|
// Generate suggested follow-up questions
|
|
const suggestedQuestions = generateFollowUpQuestions(
|
|
messageText,
|
|
accumulatedMessage,
|
|
3
|
|
);
|
|
|
|
// Add the complete message to messages
|
|
const assistantMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: 'assistant',
|
|
content: accumulatedMessage,
|
|
timestamp: new Date(),
|
|
suggestedQuestions,
|
|
};
|
|
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;
|
|
|
|
// Generate suggested follow-up questions
|
|
const suggestedQuestions = generateFollowUpQuestions(
|
|
messageText,
|
|
responseData.message,
|
|
3
|
|
);
|
|
|
|
const assistantMessage: Message = {
|
|
id: (Date.now() + 1).toString(),
|
|
role: 'assistant',
|
|
content: responseData.message,
|
|
timestamp: new Date(responseData.timestamp),
|
|
suggestedQuestions,
|
|
};
|
|
|
|
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="medium" sx={{ minWidth: 48, minHeight: 48 }} 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="medium"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isMobile) {
|
|
handleContextMenu(e, conversation.id);
|
|
} else {
|
|
setConversationToDelete(conversation.id);
|
|
setDeleteDialogOpen(true);
|
|
}
|
|
}}
|
|
sx={{ ml: 1, minWidth: 48, minHeight: 48 }}
|
|
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' }} alt="User">
|
|
<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 }} alt="AI Assistant">
|
|
<Person />
|
|
</Avatar>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Suggested Follow-up Questions */}
|
|
{message.role === 'assistant' && message.suggestedQuestions && message.suggestedQuestions.length > 0 && (
|
|
<Box sx={{ maxWidth: '70%', ml: 7 }}>
|
|
<SuggestedQuestions
|
|
questions={message.suggestedQuestions}
|
|
onQuestionClick={handleSuggestedQuestion}
|
|
loading={isLoading}
|
|
/>
|
|
</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' }} alt="User">
|
|
<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>
|
|
);
|
|
};
|