Spaces:
Running
Running
| 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 */} | |
| <div className="flex-1 overflow-y-auto px-4 py-6"> | |
| <div className="max-w-3xl mx-auto"> | |
| {/* Empty State */} | |
| {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-[60vh] text-center" | |
| > | |
| {/* CA Assistant Avatar */} | |
| <motion.div | |
| initial={{ scale: 0.8 }} | |
| animate={{ scale: 1 }} | |
| transition={{ duration: 0.5, delay: 0.2 }} | |
| className={`w-20 h-20 rounded-full flex items-center justify-center 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-10 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 */} | |
| <motion.h2 | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.4 }} | |
| className="text-2xl md:text-3xl font-bold mb-3 gradient-text" | |
| > | |
| Hello! I'm your CA Study Assistant | |
| </motion.h2> | |
| <motion.p | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.5 }} | |
| className={`text-lg mb-8 ${darkMode ? 'text-gray-300' : 'text-gray-600'}`} | |
| > | |
| 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 */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.6 }} | |
| className="w-full max-w-2xl" | |
| > | |
| <h3 className={`text-sm font-semibold mb-4 ${ | |
| darkMode ? 'text-gray-400' : 'text-gray-500' | |
| }`}> | |
| Try asking me about: | |
| </h3> | |
| <div className="grid grid-cols-1 md:grid-cols-2 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-4 rounded-xl text-left transition-all ${ | |
| darkMode | |
| ? 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300' | |
| : 'bg-gray-50 hover:bg-gray-100 border-gray-200 text-gray-700' | |
| } border hover:border-primary-300 hover:shadow-md`} | |
| > | |
| <span className="text-2xl mr-3">{suggestion.icon}</span> | |
| <span className="font-medium">{suggestion.text}</span> | |
| </motion.button> | |
| ))} | |
| </div> | |
| </motion.div> | |
| {/* Upload Reminder */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 1.2 }} | |
| className={`mt-8 p-4 rounded-xl ${ | |
| 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-5 h-5 mr-2 ${ | |
| 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-sm ${ | |
| darkMode ? 'text-primary-300' : 'text-primary-700' | |
| }`}> | |
| 💡 Upload your study materials for more specific and detailed 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 */} | |
| <AnimatePresence> | |
| {showFileUploader && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" | |
| onClick={() => setShowFileUploader(false)} | |
| > | |
| <motion.div | |
| initial={{ scale: 0.9, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| exit={{ scale: 0.9, opacity: 0 }} | |
| onClick={(e) => e.stopPropagation()} | |
| className={`max-w-md w-full p-6 rounded-2xl ${ | |
| darkMode ? 'bg-gray-800' : 'bg-white' | |
| } shadow-2xl`} | |
| > | |
| <h3 className="text-lg font-semibold mb-4">Upload Document</h3> | |
| <FileUploader darkMode={darkMode} onClose={() => setShowFileUploader(false)} /> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Input Area */} | |
| <div className={`border-t ${ | |
| darkMode ? 'border-gray-700/50 bg-gray-900/95' : 'border-gray-200/50 bg-white/95' | |
| } backdrop-blur-sm p-6`}> | |
| <div className="max-w-3xl mx-auto"> | |
| <form onSubmit={handleSubmit} className="relative"> | |
| {/* Enhanced Input Container */} | |
| <div className={`relative overflow-hidden rounded-2xl 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-xl hover:shadow-2xl focus-within:shadow-2xl`}> | |
| {/* Subtle Inner Glow */} | |
| <div className={`absolute inset-0 opacity-0 focus-within:opacity-100 transition-opacity duration-300 ${ | |
| darkMode | |
| ? 'bg-gradient-to-br from-primary-900/20 to-purple-900/20' | |
| : 'bg-gradient-to-br from-primary-50/50 to-purple-50/50' | |
| }`} /> | |
| {/* Input Content */} | |
| <div className="relative flex items-end space-x-4 p-4"> | |
| {/* File Upload Button */} | |
| <motion.button | |
| type="button" | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.95 }} | |
| onClick={() => setShowFileUploader(true)} | |
| className={`flex-shrink-0 p-3 rounded-xl transition-all duration-200 ${ | |
| darkMode | |
| ? 'hover:bg-gray-700/70 text-gray-400 hover:text-primary-400 hover:shadow-lg' | |
| : 'hover:bg-gray-100/70 text-gray-500 hover:text-primary-600 hover:shadow-md' | |
| } relative group backdrop-blur-sm`} | |
| title="Upload document" | |
| > | |
| <svg className="w-5 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> | |
| {/* Enhanced Tooltip */} | |
| <div className={`absolute -top-14 left-1/2 transform -translate-x-1/2 px-3 py-2 rounded-lg text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-all duration-200 ${ | |
| darkMode ? 'bg-gray-800 text-white shadow-xl border border-gray-700' : 'bg-gray-900 text-white shadow-xl' | |
| }`}> | |
| Upload documents | |
| <div className={`absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent ${ | |
| darkMode ? 'border-t-gray-800' : 'border-t-gray-900' | |
| }`} /> | |
| </div> | |
| </motion.button> | |
| {/* Enhanced Text Input */} | |
| <div className="flex-1 relative"> | |
| <textarea | |
| ref={textareaRef} | |
| value={message} | |
| onChange={(e) => setMessage(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={messages.length === 0 ? "Hi! Ask me about accounting, finance, taxation, or upload your study materials..." : "Ask a follow-up question..."} | |
| className={`w-full resize-none border-none outline-none bg-transparent py-3 px-2 text-base leading-relaxed ${ | |
| darkMode ? 'text-white placeholder-gray-400' : 'text-gray-900 placeholder-gray-500' | |
| } placeholder:text-sm placeholder:leading-relaxed`} | |
| rows={1} | |
| disabled={isLoading} | |
| style={{ | |
| minHeight: '24px', | |
| maxHeight: '120px', | |
| lineHeight: '1.5' | |
| }} | |
| /> | |
| {/* Input Focus Indicator */} | |
| <div className={`absolute left-0 bottom-0 h-0.5 w-0 bg-gradient-to-r from-primary-500 to-purple-500 transition-all duration-300 ${ | |
| message.trim() ? 'w-full' : 'group-focus-within:w-full' | |
| }`} /> | |
| </div> | |
| {/* Enhanced Send Button */} | |
| <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 rounded-xl transition-all duration-200 relative group ${ | |
| message.trim() && !isLoading | |
| ? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-lg hover:shadow-xl' | |
| : 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-5 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-5 h-5" /> | |
| )} | |
| {/* Enhanced Send Button Glow Effect */} | |
| {message.trim() && !isLoading && ( | |
| <div className="absolute inset-0 rounded-xl bg-gradient-to-r from-primary-600 to-primary-700 opacity-0 group-hover:opacity-30 transition-opacity duration-200 blur-lg -z-10"></div> | |
| )} | |
| </motion.button> | |
| </div> | |
| {/* Bottom Border Accent */} | |
| <div className={`absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-primary-500 to-transparent opacity-0 focus-within:opacity-100 transition-opacity duration-300`} /> | |
| </div> | |
| </form> | |
| {/* Footer Text */} | |
| <motion.p | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ delay: 0.3 }} | |
| className={`text-xs text-center mt-3 ${ | |
| 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; |