Complete Phase 3: Add conversation management capabilities to AI chat

- Add conversation delete functionality with confirmation dialog
- Add conversation rename functionality with input modal
- Implement three-dot menu for each conversation item
- Add proper authentication token key consistency
- Fix JSX structure issues in floating chat component
- Integrate full CRUD operations with conversation API

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
andupetcu
2025-09-22 12:05:03 +03:00
parent 86b7ff377b
commit 382fb93de2

View File

@@ -18,6 +18,12 @@ import {
Slide,
Grow,
Zoom,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material'
import {
Chat,
@@ -31,8 +37,13 @@ import {
Minimize,
Launch,
History,
Add,
Delete,
CircularProgress,
MoreVert,
Edit,
} from '@mui/icons-material'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import ReactMarkdown from 'react-markdown'
@@ -43,6 +54,16 @@ interface ChatMessage {
timestamp: Date
}
interface Conversation {
id: string
title: string
language: string
messageCount: number
lastMessage: ChatMessage | null
lastMessageAt: string
createdAt: string
}
export default function FloatingChat() {
const theme = useTheme()
const t = useTranslations('chat')
@@ -63,6 +84,18 @@ export default function FloatingChat() {
])
const [inputMessage, setInputMessage] = useState('')
const [isLoading, setIsLoading] = useState(false)
// Conversation management state
const [conversations, setConversations] = useState<Conversation[]>([])
const [activeConversationId, setActiveConversationId] = useState<string | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [authToken, setAuthToken] = useState<string | null>(null)
const [isLoadingConversations, setIsLoadingConversations] = useState(false)
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null)
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null)
const [showRenameDialog, setShowRenameDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [newTitle, setNewTitle] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
@@ -91,6 +124,204 @@ export default function FloatingChat() {
return () => window.removeEventListener('floating-chat:open', handler as EventListener)
}, [])
// Check authentication status
useEffect(() => {
checkAuthStatus()
}, [])
// Load conversations when authenticated
useEffect(() => {
if (isAuthenticated && authToken) {
loadConversations()
}
}, [isAuthenticated, authToken, locale])
const checkAuthStatus = useCallback(async () => {
try {
const token = localStorage.getItem('authToken')
if (token) {
// Verify token with the server
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setAuthToken(token)
setIsAuthenticated(true)
} else {
localStorage.removeItem('authToken')
setIsAuthenticated(false)
setAuthToken(null)
}
}
} catch (error) {
console.error('Auth check failed:', error)
setIsAuthenticated(false)
setAuthToken(null)
}
}, [])
const loadConversations = useCallback(async () => {
if (!authToken) return
setIsLoadingConversations(true)
try {
const response = await fetch(`/api/chat/conversations?language=${locale}&limit=20`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
})
if (response.ok) {
const data = await response.json()
setConversations(data.conversations || [])
} else {
console.error('Failed to load conversations')
}
} catch (error) {
console.error('Error loading conversations:', error)
} finally {
setIsLoadingConversations(false)
}
}, [authToken, locale])
const loadConversation = useCallback(async (conversationId: string) => {
if (!authToken) return
try {
const response = await fetch(`/api/chat/conversations/${conversationId}`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
})
if (response.ok) {
const data = await response.json()
const conversation = data.conversation
// Convert messages to the expected format
const formattedMessages = conversation.messages.map((msg: any) => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: new Date(msg.timestamp)
}))
setMessages(formattedMessages)
setActiveConversationId(conversationId)
setShowHistory(false) // Close history panel
}
} catch (error) {
console.error('Error loading conversation:', error)
}
}, [authToken])
const createNewConversation = useCallback(() => {
// Reset to a new conversation
setMessages([
{
id: '1',
role: 'assistant',
content: locale === 'ro'
? 'Bună ziua! Sunt asistentul tău AI pentru întrebări biblice. Cum te pot ajuta astăzi să înțelegi mai bine Scriptura?'
: 'Hello! I am your AI assistant for biblical questions. How can I help you understand Scripture better today?',
timestamp: new Date(),
}
])
setActiveConversationId(null)
setShowHistory(false)
}, [locale])
const handleMenuOpen = useCallback((event: React.MouseEvent<HTMLElement>, conversationId: string) => {
event.stopPropagation()
setMenuAnchorEl(event.currentTarget)
setSelectedConversationId(conversationId)
}, [])
const handleMenuClose = useCallback(() => {
setMenuAnchorEl(null)
setSelectedConversationId(null)
}, [])
const handleRenameClick = useCallback(() => {
const conversation = conversations.find(c => c.id === selectedConversationId)
if (conversation) {
setNewTitle(conversation.title)
setShowRenameDialog(true)
}
handleMenuClose()
}, [conversations, selectedConversationId])
const handleDeleteClick = useCallback(() => {
setShowDeleteDialog(true)
handleMenuClose()
}, [])
const handleRenameSubmit = useCallback(async () => {
if (!selectedConversationId || !newTitle.trim()) return
try {
const token = localStorage.getItem('authToken')
if (!token) return
const response = await fetch(`/api/chat/conversations/${selectedConversationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ title: newTitle.trim() })
})
if (response.ok) {
setConversations(prev => prev.map(conv =>
conv.id === selectedConversationId
? { ...conv, title: newTitle.trim() }
: conv
))
}
} catch (error) {
console.error('Error renaming conversation:', error)
}
setShowRenameDialog(false)
setSelectedConversationId(null)
setNewTitle('')
}, [selectedConversationId, newTitle])
const handleDeleteConfirm = useCallback(async () => {
if (!selectedConversationId) return
try {
const token = localStorage.getItem('authToken')
if (!token) return
const response = await fetch(`/api/chat/conversations/${selectedConversationId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setConversations(prev => prev.filter(conv => conv.id !== selectedConversationId))
// If we deleted the active conversation, clear the current chat
if (activeConversationId === selectedConversationId) {
setActiveConversationId(null)
setMessages([])
}
}
} catch (error) {
console.error('Error deleting conversation:', error)
}
setShowDeleteDialog(false)
setSelectedConversationId(null)
}, [selectedConversationId, activeConversationId])
const handleSendMessage = async () => {
if (!inputMessage.trim() || isLoading) return
@@ -106,14 +337,22 @@ export default function FloatingChat() {
setIsLoading(true)
try {
const headers: any = {
'Content-Type': 'application/json',
}
// Add authentication if available
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
message: inputMessage,
history: messages.slice(-5),
conversationId: activeConversationId,
history: messages.slice(-5), // Fallback for anonymous users
locale: locale,
}),
})
@@ -134,6 +373,15 @@ export default function FloatingChat() {
}
setMessages(prev => [...prev, assistantMessage])
// Update conversation state if we got a conversationId back
if (data.conversationId && isAuthenticated) {
if (!activeConversationId) {
setActiveConversationId(data.conversationId)
}
// Refresh conversations list to update last message
loadConversations()
}
} catch (error) {
console.error('Error sending message:', error)
const errorMessage: ChatMessage = {
@@ -287,29 +535,170 @@ export default function FloatingChat() {
maxHeight: '300px',
overflowY: 'auto'
}}>
<Typography variant="h6" sx={{ mb: 2 }}>
Chat History
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="body2" color="text.secondary" textAlign="center" sx={{ py: 2 }}>
📝 Chat history will appear here
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Chat History
</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center">
Sign in to save your conversations
</Typography>
<Button
variant="outlined"
size="small"
sx={{ mt: 1 }}
onClick={() => setShowHistory(false)}
>
Close History
</Button>
<Box>
<IconButton
size="small"
onClick={createNewConversation}
title="New Conversation"
sx={{ mr: 0.5 }}
>
<Add />
</IconButton>
<IconButton
size="small"
onClick={() => setShowHistory(false)}
title="Close History"
>
<Close />
</IconButton>
</Box>
</Box>
{!isAuthenticated ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Sign in to save your conversations
</Typography>
<Button variant="outlined" size="small">
Sign In
</Button>
</Box>
) : isLoadingConversations ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={24} />
</Box>
) : conversations.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
📝 No conversations yet
</Typography>
<Typography variant="body2" color="text.secondary">
Start chatting to create your first conversation!
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{conversations.map((conversation) => (
<Paper
key={conversation.id}
sx={{
p: 2,
cursor: 'pointer',
border: activeConversationId === conversation.id ? '2px solid' : '1px solid',
borderColor: activeConversationId === conversation.id ? 'primary.main' : 'divider',
'&:hover': { bgcolor: 'action.hover' },
position: 'relative'
}}
onClick={() => loadConversation(conversation.id)}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ mb: 0.5, fontWeight: 'medium' }}>
{conversation.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
{conversation.messageCount} messages {new Date(conversation.lastMessageAt).toLocaleDateString()}
</Typography>
{conversation.lastMessage && (
<Typography variant="body2" color="text.secondary" sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
}}>
{conversation.lastMessage.content.substring(0, 60)}...
</Typography>
)}
</Box>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, conversation.id)}
sx={{ ml: 1, flexShrink: 0 }}
>
<MoreVert fontSize="small" />
</IconButton>
</Box>
</Paper>
))}
</Box>
)}
</Box>
)}
<>
{/* Conversation Menu */}
<Menu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem onClick={handleRenameClick}>
<Edit fontSize="small" sx={{ mr: 1 }} />
Rename
</MenuItem>
<MenuItem onClick={handleDeleteClick} sx={{ color: 'error.main' }}>
<Delete fontSize="small" sx={{ mr: 1 }} />
Delete
</MenuItem>
</Menu>
{/* Rename Dialog */}
<Dialog open={showRenameDialog} onClose={() => setShowRenameDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>Rename Conversation</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Conversation Title"
fullWidth
variant="outlined"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleRenameSubmit()
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowRenameDialog(false)}>Cancel</Button>
<Button
onClick={handleRenameSubmit}
variant="contained"
disabled={!newTitle.trim()}
>
Rename
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(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={() => setShowDeleteDialog(false)}>Cancel</Button>
<Button
onClick={handleDeleteConfirm}
variant="contained"
color="error"
>
Delete
</Button>
</DialogActions>
</Dialog>
{/* Suggested Questions */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>