| | import React, { useState, useRef, useEffect } from 'react'; |
| | import { MessageCircle, X, Send, Bot, Loader2, AlertCircle } from 'lucide-react'; |
| | import { CHAT_SYSTEM_PROMPT } from '../config/geminiConfig'; |
| |
|
| | interface Message { |
| | id: string; |
| | text: string; |
| | sender: 'user' | 'bot' | 'error'; |
| | timestamp: Date; |
| | } |
| |
|
| | export function ChatBot() { |
| | const [isOpen, setIsOpen] = useState(false); |
| | const [messages, setMessages] = useState<Message[]>([ |
| | { |
| | id: '1', |
| | text: "Hello! I'm Pathora AI β your colposcopy expert assistant. Ask me anything about examination techniques, findings interpretation, or management guidelines.", |
| | sender: 'bot', |
| | timestamp: new Date(), |
| | }, |
| | ]); |
| | const [inputMessage, setInputMessage] = useState(''); |
| | const [isLoading, setIsLoading] = useState(false); |
| | const messagesEndRef = useRef<HTMLDivElement>(null); |
| |
|
| | |
| | useEffect(() => { |
| | messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| | }, [messages, isLoading]); |
| |
|
| | |
| | const callGemini = async (history: Message[], userText: string): Promise<string> => { |
| | |
| | const chatHistory = history |
| | .filter(m => m.sender === 'user' || m.sender === 'bot') |
| | .map(m => ({ |
| | role: m.sender === 'bot' ? 'bot' : 'user', |
| | text: m.text, |
| | })); |
| |
|
| | const requestBody = { |
| | message: userText, |
| | history: chatHistory, |
| | system_prompt: CHAT_SYSTEM_PROMPT, |
| | }; |
| |
|
| | const res = await fetch('/api/chat', { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json' }, |
| | body: JSON.stringify(requestBody), |
| | }); |
| |
|
| | if (!res.ok) { |
| | const errData = await res.json().catch(() => ({})); |
| | throw new Error(errData?.detail || `Backend error: ${res.status}`); |
| | } |
| |
|
| | const data = await res.json(); |
| | |
| | if (data.status !== 'success' || !data.response) { |
| | throw new Error('Invalid response from backend'); |
| | } |
| | |
| | return data.response; |
| | }; |
| |
|
| | |
| | const handleSendMessage = async () => { |
| | const trimmed = inputMessage.trim(); |
| | if (!trimmed || isLoading) return; |
| |
|
| | const userMsg: Message = { |
| | id: Date.now().toString(), |
| | text: trimmed, |
| | sender: 'user', |
| | timestamp: new Date(), |
| | }; |
| |
|
| | setMessages(prev => [...prev, userMsg]); |
| | setInputMessage(''); |
| | setIsLoading(true); |
| |
|
| | try { |
| | |
| | const reply = await callGemini(messages, trimmed); |
| | setMessages(prev => [ |
| | ...prev, |
| | { id: (Date.now() + 1).toString(), text: reply, sender: 'bot', timestamp: new Date() }, |
| | ]); |
| | } catch (err: any) { |
| | setMessages(prev => [ |
| | ...prev, |
| | { |
| | id: (Date.now() + 1).toString(), |
| | text: err?.message || 'Something went wrong. Please try again.', |
| | sender: 'error', |
| | timestamp: new Date(), |
| | }, |
| | ]); |
| | } finally { |
| | setIsLoading(false); |
| | } |
| | }; |
| |
|
| | const handleKeyDown = (e: React.KeyboardEvent) => { |
| | if (e.key === 'Enter' && !e.shiftKey) { |
| | e.preventDefault(); |
| | handleSendMessage(); |
| | } |
| | }; |
| |
|
| | |
| | return ( |
| | <> |
| | {/* Toggle button */} |
| | <button |
| | onClick={() => setIsOpen(!isOpen)} |
| | className="fixed bottom-6 right-6 bg-[#05998c] hover:bg-[#047569] text-white p-4 rounded-full shadow-lg transition-all duration-300 hover:scale-110 z-50" |
| | aria-label="Open AI Chat Assistant" |
| | > |
| | {isOpen ? <X className="w-6 h-6" /> : <MessageCircle className="w-6 h-6" />} |
| | </button> |
| | |
| | {/* Chat window */} |
| | {isOpen && ( |
| | <div className="fixed bottom-20 right-6 w-80 h-[480px] bg-white rounded-xl shadow-2xl border border-gray-200 z-40 flex flex-col overflow-hidden"> |
| | {/* Header */} |
| | <div className="bg-[#05998c] text-white p-4 flex items-center gap-3 shrink-0"> |
| | <Bot className="w-6 h-6" /> |
| | <div> |
| | <h3 className="font-semibold leading-tight">Pathora AI</h3> |
| | <p className="text-xs opacity-80">Colposcopy Expert Assistant</p> |
| | </div> |
| | </div> |
| | |
| | {/* Messages */} |
| | <div className="flex-1 overflow-y-auto p-4 space-y-3"> |
| | {messages.map((message) => ( |
| | <div |
| | key={message.id} |
| | className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`} |
| | > |
| | {message.sender === 'error' ? ( |
| | <div className="flex items-start gap-2 max-w-[85%] bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs"> |
| | <AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /> |
| | <span>{message.text}</span> |
| | </div> |
| | ) : ( |
| | <div |
| | className={`max-w-[85%] p-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${message.sender === 'user' |
| | ? 'bg-[#05998c] text-white rounded-br-sm' |
| | : 'bg-gray-100 text-gray-800 rounded-bl-sm' |
| | }`} |
| | > |
| | {message.text} |
| | </div> |
| | )} |
| | </div> |
| | ))} |
| | |
| | {/* Typing indicator */} |
| | {isLoading && ( |
| | <div className="flex justify-start"> |
| | <div className="bg-gray-100 rounded-xl rounded-bl-sm p-3 flex items-center gap-2 text-gray-500 text-sm"> |
| | <Loader2 className="w-4 h-4 animate-spin" /> |
| | <span>Pathora AI is thinkingβ¦</span> |
| | </div> |
| | </div> |
| | )} |
| | |
| | <div ref={messagesEndRef} /> |
| | </div> |
| | |
| | {/* Input */} |
| | <div className="p-3 border-t border-gray-200 shrink-0"> |
| | <div className="flex gap-2"> |
| | <input |
| | type="text" |
| | value={inputMessage} |
| | onChange={(e) => setInputMessage(e.target.value)} |
| | onKeyDown={handleKeyDown} |
| | disabled={isLoading} |
| | placeholder="Ask about colposcopy findingsβ¦" |
| | className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#05998c] focus:border-transparent text-sm outline-none disabled:opacity-60" |
| | /> |
| | <button |
| | onClick={handleSendMessage} |
| | disabled={!inputMessage.trim() || isLoading} |
| | className="bg-[#05998c] hover:bg-[#047569] disabled:opacity-50 disabled:cursor-not-allowed text-white p-2 rounded-lg transition-colors shrink-0" |
| | aria-label="Send message" |
| | > |
| | {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />} |
| | </button> |
| | </div> |
| | </div> |
| | </div> |
| | )} |
| | </> |
| | ); |
| | } |