Spaces:
Running
Running
| "use client" | |
| import { useState, useRef, useEffect, useCallback } from "react" | |
| import { motion, AnimatePresence } from "framer-motion" | |
| import type { AnalysisResult } from "@/app/page" | |
| import { VoiceRecorder } from "./voice-recorder" | |
| import { apiGet, apiPost } from "@/lib/api" | |
| const WELCOME = "مرحباً! أنا مساعدك الطبي. كيف يمكنني مساعدتك اليوم؟" | |
| const faqQuestions = [ | |
| { id: 1, text: "ما سبب الشعور بالتعب المستمر؟", color: "primary" }, | |
| { id: 2, text: "كيف أرفع تحليلي؟", color: "secondary" }, | |
| { id: 3, text: "ماذا تنصحني لتحسين صحتي؟", color: "muted" }, | |
| ] | |
| interface ChatBotProps { | |
| analysisResult?: AnalysisResult | null | |
| sessionId?: string | |
| } | |
| type Message = { role: "assistant" | "user"; content: string } | |
| export function ChatBot({ analysisResult, sessionId = "anonymous" }: ChatBotProps) { | |
| const [isOpen, setIsOpen] = useState(false) | |
| const [messages, setMessages] = useState<Message[]>([{ role: "assistant", content: WELCOME }]) | |
| const [inputValue, setInputValue] = useState("") | |
| const [isTyping, setIsTyping] = useState(false) | |
| const [historyLoaded, setHistoryLoaded] = useState(false) | |
| const messagesEndRef = useRef<HTMLDivElement>(null) | |
| const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant")?.content ?? "" | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) | |
| }, [messages, isTyping]) | |
| // ── Load chat history when panel first opens ────────────────── | |
| useEffect(() => { | |
| if (!isOpen || historyLoaded || sessionId === "anonymous") return | |
| setHistoryLoaded(true) | |
| apiGet(`/api/chat/history/${encodeURIComponent(sessionId)}`, { limit: 30 }) | |
| .then((r) => r.json()) | |
| .then(({ messages: saved }: { messages: Message[] }) => { | |
| if (saved && saved.length > 0) { | |
| setMessages([{ role: "assistant", content: WELCOME }, ...saved]) | |
| } | |
| }) | |
| .catch(() => {}) | |
| }, [isOpen, historyLoaded, sessionId]) | |
| // ── Persist exchange to backend ─────────────────────────────── | |
| const saveExchange = useCallback( | |
| (userMsg: string, assistantMsg: string) => { | |
| if (sessionId === "anonymous" || !assistantMsg.trim()) return | |
| apiPost("/api/chat/save", { | |
| session_id: sessionId, | |
| messages: [ | |
| { role: "user", content: userMsg }, | |
| { role: "assistant", content: assistantMsg }, | |
| ], | |
| }).catch(() => {}) | |
| }, | |
| [sessionId] | |
| ) | |
| const sendMessage = async (text: string) => { | |
| if (!text.trim() || isTyping) return | |
| const userMessage: Message = { role: "user", content: text } | |
| const updatedMessages = [...messages, userMessage] | |
| setMessages(updatedMessages) | |
| setInputValue("") | |
| setIsTyping(true) | |
| const history = updatedMessages.slice(0, -1).map((m) => ({ | |
| role: m.role, | |
| content: m.content, | |
| })) | |
| let assistantReply = "" | |
| try { | |
| const res = await apiPost("/api/chat", { | |
| query: text, | |
| history, | |
| analysis_context: analysisResult ? JSON.stringify(analysisResult) : "", | |
| }) | |
| if (!res.ok || !res.body) throw new Error("no stream") | |
| setMessages((prev) => [...prev, { role: "assistant", content: "" }]) | |
| setIsTyping(false) | |
| const reader = res.body.getReader() | |
| const decoder = new TextDecoder() | |
| while (true) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| const chunk = decoder.decode(value, { stream: true }) | |
| assistantReply += chunk | |
| setMessages((prev) => { | |
| const updated = [...prev] | |
| updated[updated.length - 1] = { | |
| role: "assistant", | |
| content: updated[updated.length - 1].content + chunk, | |
| } | |
| return updated | |
| }) | |
| } | |
| saveExchange(text, assistantReply) | |
| } catch { | |
| setIsTyping(false) | |
| const errMsg = "تعذّر الاتصال بالخادم. تأكد من تشغيل الباكند." | |
| setMessages((prev) => [...prev, { role: "assistant", content: errMsg }]) | |
| } | |
| } | |
| return ( | |
| <> | |
| {/* ── Trigger button ── */} | |
| <motion.button | |
| initial={{ scale: 0, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| transition={{ delay: 1.5, type: "spring", stiffness: 200, damping: 15 }} | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-glow-primary flex items-center justify-center overflow-hidden gradient-primary" | |
| aria-label="فتح المساعد الذكي" | |
| > | |
| <AnimatePresence mode="wait"> | |
| {isOpen ? ( | |
| <motion.svg key="close" initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.2 }} className="w-5 h-5 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> | |
| </motion.svg> | |
| ) : ( | |
| <motion.svg key="chat" initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} transition={{ duration: 0.2 }} className="w-5 h-5 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" /> | |
| </motion.svg> | |
| )} | |
| </AnimatePresence> | |
| {!isOpen && ( | |
| <motion.div | |
| animate={{ scale: [1, 1.4, 1], opacity: [0.4, 0, 0.4] }} | |
| transition={{ duration: 3, repeat: Infinity }} | |
| className="absolute inset-0 rounded-full gradient-primary" | |
| /> | |
| )} | |
| </motion.button> | |
| {/* ── Chat panel ── */} | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 16, scale: 0.96 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, y: 16, scale: 0.96 }} | |
| transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }} | |
| className="fixed bottom-24 right-6 z-50 w-[320px] max-w-[calc(100vw-3rem)] rounded-[28px] overflow-hidden glass-strong shadow-soft" | |
| style={{ border: "1px solid var(--border)" }} | |
| > | |
| {/* Header */} | |
| <div className="px-5 py-4 flex items-center gap-3 border-b border-border/50"> | |
| <div className="w-9 h-9 rounded-full flex items-center justify-center bg-primary/15"> | |
| <svg className="w-4 h-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" /> | |
| </svg> | |
| </div> | |
| <div className="flex-1"> | |
| <span className="text-sm font-semibold text-foreground">المساعد الذكي</span> | |
| <p className="text-[10px] text-muted-foreground">مساعد طبي ذكي</p> | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div className="h-56 overflow-y-auto px-4 py-3 space-y-3"> | |
| <AnimatePresence mode="popLayout"> | |
| {messages.map((msg, i) => ( | |
| <motion.div | |
| key={i} | |
| initial={{ opacity: 0, y: 10, scale: 0.98 }} | |
| animate={{ opacity: 1, y: 0, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.98 }} | |
| transition={{ duration: 0.3 }} | |
| className={`flex ${msg.role === "user" ? "justify-start" : "justify-end"}`} | |
| > | |
| <div | |
| className={`max-w-[82%] px-3.5 py-2.5 text-foreground ${ | |
| msg.role === "user" | |
| ? "rounded-2xl rounded-tr-lg bg-primary/12 border border-primary/20" | |
| : "rounded-2xl rounded-tl-lg bg-muted border border-border/50" | |
| }`} | |
| > | |
| <p className="text-[13px] leading-relaxed whitespace-pre-line">{msg.content}</p> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| <AnimatePresence> | |
| {isTyping && ( | |
| <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex justify-end"> | |
| <div className="px-4 py-3 rounded-2xl rounded-tl-lg flex items-center gap-1.5 bg-muted border border-border/50"> | |
| {[0, 0.2, 0.4].map((delay, i) => ( | |
| <motion.span key={i} animate={{ opacity: [0.3, 1, 0.3] }} transition={{ duration: 1.2, repeat: Infinity, delay }} className="w-1.5 h-1.5 rounded-full bg-muted-foreground" /> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* FAQ quick questions */} | |
| <div className="px-4 pb-2"> | |
| <div className="flex flex-wrap gap-1.5 justify-center"> | |
| {faqQuestions.map((faq, i) => ( | |
| <motion.button | |
| key={faq.id} | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.15 + i * 0.06 }} | |
| whileHover={{ scale: 1.03, y: -1 }} | |
| whileTap={{ scale: 0.97 }} | |
| onClick={() => sendMessage(faq.text)} | |
| disabled={isTyping} | |
| className={`px-3 py-1.5 rounded-full text-[11px] font-medium text-foreground transition-all duration-200 disabled:opacity-50 border ${ | |
| faq.color === "primary" | |
| ? "bg-primary/10 border-primary/20 hover:bg-primary/15" | |
| : faq.color === "secondary" | |
| ? "bg-secondary/30 border-border/50 hover:bg-secondary/40" | |
| : "bg-muted border-border/50 hover:bg-muted/70" | |
| }`} | |
| > | |
| {faq.text} | |
| </motion.button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Input */} | |
| <div className="px-4 pb-4 pt-2"> | |
| <div className="flex items-center gap-2 rounded-full px-1 py-1 bg-input border border-border/60"> | |
| <VoiceRecorder | |
| onTranscription={(text) => sendMessage(text)} | |
| ttsText={lastAssistantMsg} | |
| disabled={isTyping} | |
| /> | |
| <input | |
| type="text" | |
| value={inputValue} | |
| onChange={(e) => setInputValue(e.target.value)} | |
| onKeyDown={(e) => e.key === "Enter" && sendMessage(inputValue)} | |
| placeholder="اكتب أو انقر الميكروفون..." | |
| disabled={isTyping} | |
| className="flex-1 px-3 py-2 bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none disabled:opacity-50" | |
| /> | |
| <motion.button | |
| whileHover={{ scale: 1.05 }} | |
| whileTap={{ scale: 0.95 }} | |
| onClick={() => sendMessage(inputValue)} | |
| disabled={isTyping || !inputValue.trim()} | |
| className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 disabled:opacity-50 gradient-primary shadow-glow-primary" | |
| aria-label="إرسال" | |
| > | |
| <svg className="w-3.5 h-3.5 text-primary-foreground rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2"> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" /> | |
| </svg> | |
| </motion.button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </> | |
| ) | |
| } | |