Spaces:
Sleeping
Sleeping
import React, { useState, useRef, useEffect } from 'react'; | |
import { motion, AnimatePresence } from 'framer-motion'; | |
import { PaperAirplaneIcon, StopIcon } from '@heroicons/react/24/solid'; | |
import MessageBubble from './MessageBubble'; | |
import TypingIndicator from './TypingIndicator'; | |
import FileUploader from './FileUploader'; | |
import { sendMessage, sendMessageStream } from '../services/api'; | |
import toast from 'react-hot-toast'; | |
const ChatInterface = ({ conversationId, conversations, setConversations, darkMode }) => { | |
const [message, setMessage] = useState(''); | |
const [isLoading, setIsLoading] = useState(false); | |
const [showFileUploader, setShowFileUploader] = useState(false); | |
const messagesEndRef = useRef(null); | |
const textareaRef = useRef(null); | |
const currentConversation = conversations.find(conv => conv.id === conversationId); | |
const messages = currentConversation?.messages || []; | |
const scrollToBottom = () => { | |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
}; | |
useEffect(() => { | |
scrollToBottom(); | |
}, [messages]); | |
const handleSubmit = async (e) => { | |
e.preventDefault(); | |
if (!message.trim() || isLoading) return; | |
const userMessage = { | |
id: Date.now().toString(), | |
role: 'user', | |
content: message.trim(), | |
timestamp: new Date(), | |
}; | |
// Add user message immediately | |
setConversations(prev => prev.map(conv => | |
conv.id === conversationId | |
? { | |
...conv, | |
messages: [...conv.messages, userMessage], | |
title: conv.messages.length === 0 ? message.slice(0, 50) + '...' : conv.title | |
} | |
: conv | |
)); | |
setMessage(''); | |
setIsLoading(true); | |
const assistantMessageId = (Date.now() + 1).toString(); | |
try { | |
let fullResponse = ''; | |
// Add a placeholder for the assistant's message | |
setConversations(prev => prev.map(conv => | |
conv.id === conversationId | |
? { ...conv, messages: [...conv.messages, { id: assistantMessageId, role: 'assistant', content: '', timestamp: new Date() }] } | |
: conv | |
)); | |
await sendMessageStream(message.trim(), (chunk) => { | |
fullResponse += chunk; | |
setConversations(prev => prev.map(conv => | |
conv.id === conversationId | |
? { | |
...conv, | |
messages: conv.messages.map(msg => | |
msg.id === assistantMessageId | |
? { ...msg, content: fullResponse } | |
: msg | |
), | |
} | |
: conv | |
)); | |
}); | |
} catch (error) { | |
toast.error('Failed to send message. Please try again.'); | |
console.error('Error sending message:', error); | |
// Optional: remove placeholder on error | |
setConversations(prev => prev.map(conv => | |
conv.id === conversationId | |
? { ...conv, messages: conv.messages.filter(msg => msg.id !== assistantMessageId) } | |
: conv | |
)); | |
} finally { | |
setIsLoading(false); | |
} | |
}; | |
const handleKeyDown = (e) => { | |
if (e.key === 'Enter' && !e.shiftKey) { | |
e.preventDefault(); | |
handleSubmit(e); | |
} | |
}; | |
const adjustTextareaHeight = () => { | |
const textarea = textareaRef.current; | |
if (textarea) { | |
textarea.style.height = 'auto'; | |
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; | |
} | |
}; | |
useEffect(() => { | |
adjustTextareaHeight(); | |
}, [message]); | |
return ( | |
<div className="flex flex-col h-screen"> | |
{/* Messages Container - Mobile optimized */} | |
<div className="flex-1 overflow-y-auto px-3 md:px-4 py-4 md:py-6"> | |
<div className="max-w-4xl mx-auto"> | |
{/* Empty State - Mobile optimized */} | |
{messages.length === 0 && !isLoading && ( | |
<motion.div | |
initial={{ opacity: 0, y: 20 }} | |
animate={{ opacity: 1, y: 0 }} | |
transition={{ duration: 0.6 }} | |
className="flex flex-col items-center justify-center min-h-[50vh] md:min-h-[60vh] text-center px-4" | |
> | |
{/* CA Assistant Avatar - Larger on mobile */} | |
<motion.div | |
initial={{ scale: 0.8 }} | |
animate={{ scale: 1 }} | |
transition={{ duration: 0.5, delay: 0.2 }} | |
className={`w-16 h-16 md:w-20 md:h-20 rounded-full flex items-center justify-center mb-4 md:mb-6 ${ | |
darkMode | |
? 'bg-gradient-to-br from-primary-600 to-purple-600' | |
: 'bg-gradient-to-br from-primary-500 to-purple-500' | |
} shadow-lg`} | |
> | |
<svg className="w-8 h-8 md:w-10 md:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} | |
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> | |
</svg> | |
</motion.div> | |
{/* Welcome Message - Mobile optimized */} | |
<motion.h2 | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 1 }} | |
transition={{ delay: 0.4 }} | |
className="text-xl md:text-2xl lg:text-3xl font-bold mb-2 md:mb-3 gradient-text text-center" | |
> | |
Hello! I'm your CA Study Assistant | |
</motion.h2> | |
<motion.p | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 1 }} | |
transition={{ delay: 0.5 }} | |
className={`text-base md:text-lg mb-6 md:mb-8 px-2 ${darkMode ? 'text-gray-300' : 'text-gray-600'} text-center max-w-md`} | |
> | |
I'm here to help you with accounting, finance, taxation, and auditing concepts. | |
Ask me anything or upload your study materials! | |
</motion.p> | |
{/* Quick Start Suggestions - Mobile optimized */} | |
<motion.div | |
initial={{ opacity: 0, y: 20 }} | |
animate={{ opacity: 1, y: 0 }} | |
transition={{ delay: 0.6 }} | |
className="w-full max-w-lg md:max-w-2xl" | |
> | |
<h3 className={`text-xs md:text-sm font-semibold mb-3 md:mb-4 ${ | |
darkMode ? 'text-gray-400' : 'text-gray-500' | |
} text-center`}> | |
Try asking me about: | |
</h3> | |
{/* Mobile: Single column, Desktop: Two columns */} | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 md:gap-3"> | |
{[ | |
{ icon: "📊", text: "Financial statement analysis", query: "Explain financial statement analysis" }, | |
{ icon: "💰", text: "Depreciation methods", query: "What are different depreciation methods?" }, | |
{ icon: "🏦", text: "Working capital management", query: "Explain working capital management" }, | |
{ icon: "📈", text: "Ratio analysis", query: "How to perform ratio analysis?" }, | |
{ icon: "📋", text: "Auditing procedures", query: "What are key auditing procedures?" }, | |
{ icon: "💼", text: "Tax planning strategies", query: "Explain tax planning strategies" } | |
].map((suggestion, index) => ( | |
<motion.button | |
key={index} | |
initial={{ opacity: 0, x: -20 }} | |
animate={{ opacity: 1, x: 0 }} | |
transition={{ delay: 0.7 + index * 0.1 }} | |
whileHover={{ scale: 1.02, y: -2 }} | |
whileTap={{ scale: 0.98 }} | |
onClick={() => setMessage(suggestion.query)} | |
className={`flex items-center p-3 md:p-4 rounded-lg md:rounded-xl text-left transition-all touch-manipulation ${ | |
darkMode | |
? 'bg-gray-800 hover:bg-gray-700 active:bg-gray-600 border-gray-700 text-gray-300' | |
: 'bg-gray-50 hover:bg-gray-100 active:bg-gray-200 border-gray-200 text-gray-700' | |
} border hover:border-primary-300 hover:shadow-md active:shadow-lg`} | |
> | |
<span className="text-xl md:text-2xl mr-2 md:mr-3 flex-shrink-0">{suggestion.icon}</span> | |
<span className="font-medium text-sm md:text-base">{suggestion.text}</span> | |
</motion.button> | |
))} | |
</div> | |
</motion.div> | |
{/* Upload Reminder - Mobile optimized */} | |
<motion.div | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 1 }} | |
transition={{ delay: 1.2 }} | |
className={`mt-6 md:mt-8 p-3 md:p-4 rounded-lg md:rounded-xl max-w-md ${ | |
darkMode | |
? 'bg-primary-900/20 border-primary-700/30' | |
: 'bg-primary-50 border-primary-200' | |
} border`} | |
> | |
<div className="flex items-center justify-center"> | |
<svg className={`w-4 h-4 md:w-5 md:h-5 mr-2 flex-shrink-0 ${ | |
darkMode ? 'text-primary-400' : 'text-primary-600' | |
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} | |
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> | |
</svg> | |
<span className={`text-xs md:text-sm text-center ${ | |
darkMode ? 'text-primary-300' : 'text-primary-700' | |
}`}> | |
💡 Upload your study materials for more specific answers | |
</span> | |
</div> | |
</motion.div> | |
</motion.div> | |
)} | |
{/* Messages */} | |
<AnimatePresence> | |
{messages.map((msg, index) => ( | |
<MessageBubble | |
key={msg.id} | |
message={msg} | |
darkMode={darkMode} | |
isLast={index === messages.length - 1} | |
/> | |
))} | |
</AnimatePresence> | |
{isLoading && <TypingIndicator darkMode={darkMode} />} | |
<div ref={messagesEndRef} /> | |
</div> | |
</div> | |
{/* File Uploader Modal - Mobile optimized */} | |
<AnimatePresence> | |
{showFileUploader && ( | |
<motion.div | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 1 }} | |
exit={{ opacity: 0 }} | |
className="fixed inset-0 bg-black bg-opacity-50 flex items-end md:items-center justify-center z-50 p-0 md:p-4" | |
onClick={() => setShowFileUploader(false)} | |
> | |
<motion.div | |
initial={{ y: '100%', opacity: 0 }} | |
animate={{ y: 0, opacity: 1 }} | |
exit={{ y: '100%', opacity: 0 }} | |
onClick={(e) => e.stopPropagation()} | |
className={`w-full max-w-md md:max-w-lg p-4 md:p-6 rounded-t-3xl md:rounded-2xl ${ | |
darkMode ? 'bg-gray-800' : 'bg-white' | |
} shadow-2xl max-h-[80vh] overflow-y-auto`} | |
> | |
<h3 className="text-lg md:text-xl font-semibold mb-4 text-center md:text-left">Upload Document</h3> | |
<FileUploader darkMode={darkMode} onClose={() => setShowFileUploader(false)} /> | |
</motion.div> | |
</motion.div> | |
)} | |
</AnimatePresence> | |
{/* Input Area - Mobile-first optimized */} | |
<div className={`border-t ${ | |
darkMode ? 'border-gray-700/50 bg-gray-900/95' : 'border-gray-200/50 bg-white/95' | |
} backdrop-blur-sm p-3 md:p-6 pb-safe`}> | |
<div className="max-w-4xl mx-auto"> | |
<form onSubmit={handleSubmit} className="relative"> | |
{/* Mobile-optimized Input Container */} | |
<div className={`relative overflow-hidden rounded-2xl md:rounded-3xl border-2 transition-all duration-300 ${ | |
darkMode | |
? 'bg-gradient-to-br from-gray-800 to-gray-900 border-gray-600 focus-within:border-primary-500 focus-within:from-gray-700 focus-within:to-gray-800' | |
: 'bg-gradient-to-br from-white to-gray-50 border-gray-300 focus-within:border-primary-500 focus-within:from-blue-50 focus-within:to-white' | |
} focus-within:ring-4 focus-within:ring-primary-500/20 shadow-lg md:shadow-xl hover:shadow-xl md:hover:shadow-2xl focus-within:shadow-xl md:focus-within:shadow-2xl`}> | |
{/* Input Content - Mobile optimized */} | |
<div className="relative flex items-end space-x-2 md:space-x-4 p-3 md:p-4"> | |
{/* File Upload Button - Larger touch target */} | |
<motion.button | |
type="button" | |
whileHover={{ scale: 1.05 }} | |
whileTap={{ scale: 0.95 }} | |
onClick={() => setShowFileUploader(true)} | |
className={`flex-shrink-0 p-3 md:p-3 rounded-xl md:rounded-xl transition-all duration-200 touch-manipulation ${ | |
darkMode | |
? 'hover:bg-gray-700/70 active:bg-gray-600/70 text-gray-400 hover:text-primary-400' | |
: 'hover:bg-gray-100/70 active:bg-gray-200/70 text-gray-500 hover:text-primary-600' | |
} relative group backdrop-blur-sm min-h-[44px] min-w-[44px] flex items-center justify-center`} | |
title="Upload document" | |
> | |
<svg className="w-6 h-6 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} | |
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /> | |
</svg> | |
</motion.button> | |
{/* Text Input - Mobile optimized */} | |
<div className="flex-1 relative"> | |
<textarea | |
ref={textareaRef} | |
value={message} | |
onChange={(e) => setMessage(e.target.value)} | |
onKeyDown={handleKeyDown} | |
placeholder={messages.length === 0 ? "Ask me about accounting, finance, taxation..." : "Ask a follow-up question..."} | |
className={`w-full resize-none border-none outline-none bg-transparent py-3 md:py-3 px-2 text-base md:text-base leading-relaxed ${ | |
darkMode ? 'text-white placeholder-gray-400' : 'text-gray-900 placeholder-gray-500' | |
} placeholder:text-sm md:placeholder:text-sm placeholder:leading-relaxed touch-manipulation`} | |
rows={1} | |
disabled={isLoading} | |
style={{ | |
minHeight: '24px', | |
maxHeight: '100px', | |
lineHeight: '1.5' | |
}} | |
/> | |
</div> | |
{/* Send Button - Larger touch target */} | |
<motion.button | |
type="submit" | |
disabled={!message.trim() || isLoading} | |
whileHover={message.trim() && !isLoading ? { scale: 1.05 } : {}} | |
whileTap={message.trim() && !isLoading ? { scale: 0.95 } : {}} | |
className={`flex-shrink-0 p-3 md:p-3 rounded-xl md:rounded-xl transition-all duration-200 relative group touch-manipulation min-h-[44px] min-w-[44px] flex items-center justify-center ${ | |
message.trim() && !isLoading | |
? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 active:from-primary-800 active:to-primary-900 text-white shadow-lg hover:shadow-xl active:shadow-2xl' | |
: darkMode | |
? 'bg-gray-600/50 text-gray-400 hover:bg-gray-600/70' | |
: 'bg-gray-300/50 text-gray-500 hover:bg-gray-300/70' | |
} disabled:cursor-not-allowed`} | |
title={isLoading ? "Stop generation" : "Send message"} | |
> | |
{isLoading ? ( | |
<div className="relative"> | |
<StopIcon className="w-6 h-6 md:w-5 md:h-5" /> | |
<div className="absolute inset-0 border-2 border-white border-t-transparent rounded-full animate-spin opacity-50"></div> | |
</div> | |
) : ( | |
<PaperAirplaneIcon className="w-6 h-6 md:w-5 md:h-5" /> | |
)} | |
</motion.button> | |
</div> | |
</div> | |
</form> | |
{/* Footer Text - Mobile optimized */} | |
<motion.p | |
initial={{ opacity: 0 }} | |
animate={{ opacity: 1 }} | |
transition={{ delay: 0.3 }} | |
className={`text-xs text-center mt-2 md:mt-3 px-2 ${ | |
darkMode ? 'text-gray-500' : 'text-gray-400' | |
}`} | |
> | |
⚡ Powered by AI • CA Study Assistant can make mistakes. Consider checking important information. | |
</motion.p> | |
</div> | |
</div> | |
</div> | |
); | |
}; | |
export default ChatInterface; |