'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([]); 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([]); const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0); const [conversations, setConversations] = useState([]); const [currentConversationId, setCurrentConversationId] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [conversationToDelete, setConversationToDelete] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>(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(null); const thinkingIntervalRef = useRef(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(); 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 = ( {t('history.title')} {isMobile && ( setDrawerOpen(false)} size="small" aria-label={t('interface.closeDrawer')}> )} {conversations.length === 0 ? ( {t('history.noHistory')} ) : ( organizeConversations().map((group) => ( {/* Group Header */} toggleGroupCollapse(group.name)} sx={{ py: 1, px: 2, bgcolor: 'background.default', '&:hover': { bgcolor: 'action.hover' }, }} > {group.isCollapsed ? : } {group.isCollapsed ? : } {/* Conversations in Group */} {group.conversations.map((conversation) => ( 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', }, }, }} > { 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 ? : } ))} )) )} ); return ( {/* Conversation History Sidebar - Desktop */} {!isMobile && ( {drawerContent} )} {/* Mobile Drawer */} setDrawerOpen(false)} ModalProps={{ keepMounted: true, // Better mobile performance }} > {drawerContent} {/* Chat Area */} {/* Header */} {isMobile && ( setDrawerOpen(true)} edge="start"> )} {t('interface.assistantTitle')} {t('interface.assistantSubtitle')} {/* Messages Container */} {messages.length === 0 && ( {t('interface.greeting', { name: user?.name })} {suggestedQuestions.map((question, index) => ( handleSuggestedQuestion(question)} sx={{ py: 2, borderRadius: 2, fontSize: '0.875rem', bgcolor: 'background.paper', border: 1, borderColor: 'divider', '&:hover': { bgcolor: 'primary.light', borderColor: 'primary.main', }, }} /> ))} )} {messages.map((message) => ( {message.role === 'assistant' && ( )} {message.role === 'assistant' ? ( <> {message.content} {message.timestamp.toLocaleTimeString()} ) : ( <> {message.content} {message.timestamp.toLocaleTimeString()} )} {message.role === 'user' && ( )} ))} {/* Streaming Message Display */} {streamingMessage && ( {streamingMessage} )} {/* Loading Indicator (shown when waiting for first token) */} {isLoading && !streamingMessage && ( {currentThinkingMessages[currentThinkingIndex] || t('chat.thinking')} )}
{/* Input Area */} 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' } }, }} /> handleSend()} disabled={!input.trim() || isLoading} sx={{ width: 48, height: 48, bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark', }, '&:disabled': { bgcolor: 'action.disabledBackground', }, }} > {t('interface.disclaimerFooter')} {/* Delete Confirmation Dialog */} setDeleteDialogOpen(false)}> {t('interface.deleteDialogTitle')} {t('interface.deleteDialogMessage')} {/* Context Menu for Conversation Actions */} { if (contextMenu) { setMoveToGroupDialog({ open: true, conversationId: contextMenu.conversationId }); } handleCloseContextMenu(); }} > {t('interface.moveToGroup')} { if (contextMenu) { setConversationToDelete(contextMenu.conversationId); setDeleteDialogOpen(true); } handleCloseContextMenu(); }} > {t('interface.delete')} {/* Move to Group Dialog */} setMoveToGroupDialog({ open: false, conversationId: null })} maxWidth="xs" fullWidth > {t('interface.moveToGroup')} { if (moveToGroupDialog.conversationId) { handleMoveToGroup(moveToGroupDialog.conversationId, null); } }} > {getExistingGroups().map((groupName) => ( { if (moveToGroupDialog.conversationId) { handleMoveToGroup(moveToGroupDialog.conversationId, groupName); } }} > ))} setNewGroupDialog(true)}> {/* Create New Group Dialog */} { setNewGroupDialog(false); setNewGroupName(''); }} maxWidth="xs" fullWidth > {t('interface.createNewGroup')} setNewGroupName(e.target.value)} onKeyPress={(e) => { if (e.key === 'Enter') { handleCreateNewGroup(); } }} /> ); };