| "use client"; |
|
|
| import { useState, useRef, useEffect } from "react"; |
| import { |
| Send, |
| Bot, |
| User, |
| Loader2, |
| Sparkles, |
| AlertCircle, |
| Mic, |
| MicOff, |
| } from "lucide-react"; |
| import ReactMarkdown from "react-markdown"; |
| import remarkGfm from "remark-gfm"; |
| import { cn } from "@/lib/utils"; |
| import { useLanguage } from "@/app/providers"; |
| import { motion, AnimatePresence } from "framer-motion"; |
|
|
| |
| function classNames(...classes: (string | undefined | null | false)[]) { |
| return classes.filter(Boolean).join(" "); |
| } |
|
|
| interface Message { |
| id: string; |
| role: "user" | "bot"; |
| content: string; |
| } |
|
|
| |
| const markdownComponents = { |
| |
| strong: ({ children }: any) => ( |
| <strong className="font-bold text-orange-500">{children}</strong> |
| ), |
| |
| table: ({ children }: any) => ( |
| <div className="overflow-x-auto my-4"> |
| <table className="border-collapse w-full text-sm">{children}</table> |
| </div> |
| ), |
| thead: ({ children }: any) => ( |
| <thead className="bg-orange-500/10 dark:bg-orange-500/20">{children}</thead> |
| ), |
| th: ({ children }: any) => ( |
| <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-bold"> |
| {children} |
| </th> |
| ), |
| td: ({ children }: any) => ( |
| <td className="border border-gray-300 dark:border-gray-600 px-3 py-2"> |
| {children} |
| </td> |
| ), |
| |
| ul: ({ children }: any) => ( |
| <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul> |
| ), |
| ol: ({ children }: any) => ( |
| <ol className="list-decimal list-outside pl-5 my-2 space-y-1"> |
| {children} |
| </ol> |
| ), |
| |
| code: ({ node, inline, className, children, ...props }: any) => { |
| const match = /language-(\w+)/.exec(className || ""); |
| return !inline ? ( |
| <div className="rounded-md overflow-hidden my-2 border border-gray-200 dark:border-gray-700"> |
| <div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs text-gray-500 font-mono border-b border-gray-200 dark:border-gray-700"> |
| {match ? match[1] : "code"} |
| </div> |
| <div className="bg-gray-50 dark:bg-[#1e1e1e] p-3 overflow-x-auto"> |
| <code className={className} {...props}> |
| {children} |
| </code> |
| </div> |
| </div> |
| ) : ( |
| <code |
| className="bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm font-mono text-orange-600 dark:text-orange-400" |
| {...props} |
| > |
| {children} |
| </code> |
| ); |
| }, |
| }; |
|
|
| |
| const Typewriter = ({ |
| text, |
| speed = 10, |
| onTyping, |
| onComplete, |
| }: { |
| text: string; |
| speed?: number; |
| onTyping?: () => void; |
| onComplete?: () => void; |
| }) => { |
| const [displayedText, setDisplayedText] = useState(""); |
| const [isTyping, setIsTyping] = useState(true); |
|
|
| useEffect(() => { |
| setDisplayedText(""); |
| setIsTyping(true); |
| let i = 0; |
| const interval = setInterval(() => { |
| if (i < text.length) { |
| setDisplayedText((prev) => prev + text.charAt(i)); |
| i++; |
| |
| onTyping?.(); |
| } else { |
| clearInterval(interval); |
| setIsTyping(false); |
| onComplete?.(); |
| } |
| }, speed); |
|
|
| return () => clearInterval(interval); |
| }, [text, speed]); |
|
|
| return ( |
| <div className="markdown-content"> |
| <ReactMarkdown |
| remarkPlugins={[remarkGfm]} |
| components={markdownComponents} |
| > |
| {displayedText} |
| </ReactMarkdown> |
| {isTyping && ( |
| <span className="inline-block w-1.5 h-4 ml-1 align-middle bg-orange-500 animate-pulse" /> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default function ChatPage() { |
| const { lang } = useLanguage(); |
| const [messages, setMessages] = useState<Message[]>([]); |
| const [inputValue, setInputValue] = useState(""); |
| const [isLoading, setIsLoading] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| const [isListening, setIsListening] = useState(false); |
| const [typedMessages, setTypedMessages] = useState<Set<string>>(new Set()); |
|
|
| const messagesEndRef = useRef<HTMLDivElement>(null); |
| const messagesContainerRef = useRef<HTMLDivElement>(null); |
| const recognitionRef = useRef<any>(null); |
|
|
| |
| const scrollToBottom = (behavior: ScrollBehavior = "smooth") => { |
| const container = messagesContainerRef.current; |
| if (!container) return; |
|
|
| |
| const isNearBottom = |
| container.scrollHeight - container.scrollTop - container.clientHeight < |
| 50; |
| if (isNearBottom) { |
| messagesEndRef.current?.scrollIntoView({ behavior, block: "end" }); |
| } |
| }; |
|
|
| useEffect(() => { |
| |
| scrollToBottom("smooth"); |
| }, [messages, isLoading]); |
|
|
| |
| const accumulatedTranscriptRef = useRef<string>(""); |
|
|
| useEffect(() => { |
| if (typeof window !== "undefined") { |
| const SpeechRecognition = |
| (window as any).SpeechRecognition || |
| (window as any).webkitSpeechRecognition; |
| if (SpeechRecognition) { |
| const recognition = new SpeechRecognition(); |
| recognition.continuous = true; |
| recognition.interimResults = true; |
| recognition.lang = lang === "id" ? "id-ID" : "en-US"; |
|
|
| recognition.onresult = (event: any) => { |
| let finalTranscript = ""; |
| let interimTranscript = ""; |
|
|
| for (let i = 0; i < event.results.length; ++i) { |
| if (event.results[i].isFinal) { |
| finalTranscript += event.results[i][0].transcript + " "; |
| } else { |
| interimTranscript += event.results[i][0].transcript; |
| } |
| } |
|
|
| |
| setInputValue(finalTranscript + interimTranscript); |
| accumulatedTranscriptRef.current = finalTranscript; |
| }; |
|
|
| recognition.onerror = (event: any) => { |
| console.error("Voice Error:", event.error); |
| if (event.error !== "no-speech") { |
| setIsListening(false); |
| } |
| }; |
|
|
| recognitionRef.current = recognition; |
| } |
| } |
| }, [lang]); |
|
|
| const toggleListening = () => { |
| if (!recognitionRef.current) { |
| alert("Browser kamu gak support voice input bro. Coba Chrome."); |
| return; |
| } |
|
|
| if (isListening) { |
| recognitionRef.current.stop(); |
| setIsListening(false); |
| } else { |
| |
| |
| recognitionRef.current.start(); |
| setIsListening(true); |
| } |
| }; |
|
|
| const handleSendMessage = async (text: string) => { |
| const messageText = text || inputValue; |
| if (!messageText.trim()) return; |
|
|
| |
| if (isListening && recognitionRef.current) { |
| recognitionRef.current.stop(); |
| setIsListening(false); |
| } |
|
|
| setError(null); |
| const userMessage: Message = { |
| id: Date.now().toString(), |
| role: "user", |
| content: messageText, |
| }; |
|
|
| setMessages((prev) => [...prev, userMessage]); |
| setInputValue(""); |
| setIsLoading(true); |
|
|
| try { |
| const apiUrl = |
| process.env.NEXT_PUBLIC_CHATBOT_URL || "http://localhost:8000/api/chat"; |
|
|
| const response = await fetch(apiUrl, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ |
| message: messageText, |
| lang: lang, |
| }), |
| }); |
|
|
| if (!response.ok) { |
| throw new Error(`Server returned ${response.status}`); |
| } |
|
|
| const data = await response.json(); |
|
|
| const botMessage: Message = { |
| id: (Date.now() + 1).toString(), |
| role: "bot", |
| content: data.response || "Maaf, saya tidak mengerti.", |
| }; |
|
|
| setMessages((prev) => [...prev, botMessage]); |
| } catch (err: any) { |
| console.error("Chat Error:", err); |
| setError( |
| "Gagal terhubung ke backend. Pastikan server API (port 8000) sudah jalan." |
| ); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| const t = { |
| en: { |
| title: "Sentimind Chat", |
| desc: "Consult about MBTI, psychology, and mental health.", |
| placeholder: "Type or use voice...", |
| thinking: "Thinking...", |
| powerBy: "Powered by Gemini. AI may make mistakes.", |
| suggestions: [ |
| "What is INTJ personality?", |
| "How to overcome social anxiety?", |
| "Explain Fe vs Fi cognitive functions", |
| "Why do INFJs feel lonely?", |
| ], |
| emptyState: "Start a conversation...", |
| }, |
| id: { |
| title: "Sentimind Chat", |
| desc: "Ngobrol santai soal MBTI, psikologi, dan kesehatan mental.", |
| placeholder: "Ketik atau ngomong langsung...", |
| thinking: "Bentar bre, mikir dulu...", |
| powerBy: "Ditenagai Gemini. AI bisa aja salah, namanya juga bot.", |
| suggestions: [ |
| "Apa itu tipe kepribadian INTJ?", |
| "Gimana cara ngilangin cemas?", |
| "Bedanya Fe sama Fi apa sih?", |
| "Kenapa INFJ sering merasa kesepian?", |
| ], |
| emptyState: "Tanya apa gitu...", |
| }, |
| }; |
|
|
| const content = t[lang] || t.en; |
|
|
| return ( |
| <div className="w-full flex flex-col pt-28 md:pt-32 font-sans min-h-screen justify-start"> |
| {/* Main Chat Content */} |
| <div className="flex-1 w-full max-w-3xl mx-auto px-4 md:px-0 flex flex-col"> |
| {/* Header (Only show if no messages) */} |
| <AnimatePresence> |
| {messages.length === 0 && ( |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -20 }} |
| transition={{ duration: 0.5 }} |
| className="flex flex-col items-center text-center space-y-6 py-10" |
| > |
| <motion.div |
| initial={{ scale: 0 }} |
| animate={{ scale: 1 }} |
| transition={{ |
| type: "spring", |
| stiffness: 260, |
| damping: 20, |
| delay: 0.1, |
| }} |
| className="p-2 bg-orange-100 dark:bg-orange-500/10 rounded-full mb-2" |
| > |
| <Sparkles className="text-orange-600 dark:text-orange-400 w-6 h-6" /> |
| </motion.div> |
| <div> |
| <h1 className="text-5xl md:text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 leading-[1.1] pb-2"> |
| {content.title} |
| </h1> |
| <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed"> |
| {content.desc} |
| </p> |
| </div> |
| |
| <motion.div |
| className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-2xl mt-12" |
| initial="hidden" |
| animate="visible" |
| variants={{ |
| hidden: { opacity: 0 }, |
| visible: { |
| opacity: 1, |
| transition: { |
| staggerChildren: 0.1, |
| }, |
| }, |
| }} |
| > |
| {content.suggestions.map((s, i) => ( |
| <motion.button |
| key={i} |
| variants={{ |
| hidden: { opacity: 0, y: 20 }, |
| visible: { opacity: 1, y: 0 }, |
| }} |
| whileHover={{ scale: 1.02 }} |
| whileTap={{ scale: 0.98 }} |
| onClick={() => handleSendMessage(s)} |
| className="p-4 text-left text-sm bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:bg-orange-50 dark:hover:bg-neutral-800 hover:border-orange-300 dark:hover:border-orange-700/50 rounded-2xl transition-colors text-gray-600 dark:text-gray-300 shadow-sm" |
| > |
| "{s}" |
| </motion.button> |
| ))} |
| </motion.div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Chat Messages */} |
| <div ref={messagesContainerRef} className="space-y-6 flex-1 mb-8"> |
| <AnimatePresence mode="popLayout"> |
| {messages.map((msg) => ( |
| <motion.div |
| key={msg.id} |
| layout |
| initial={{ opacity: 0, scale: 0.9, y: 20 }} |
| animate={{ opacity: 1, scale: 1, y: 0 }} |
| exit={{ opacity: 0, scale: 0.9 }} |
| transition={{ duration: 0.3 }} |
| className={classNames( |
| "flex gap-4 md:gap-6", |
| msg.role === "user" ? "flex-row-reverse" : "flex-row" |
| )} |
| > |
| {/* Avatar */} |
| <div |
| className={classNames( |
| "w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm mt-1", |
| msg.role === "user" |
| ? "bg-gray-200 dark:bg-neutral-700 text-gray-600 dark:text-gray-200" |
| : "bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400" |
| )} |
| > |
| {msg.role === "user" ? <User size={18} /> : <Bot size={20} />} |
| </div> |
| |
| {/* Content */} |
| <div |
| className={classNames( |
| "max-w-[85%] md:max-w-[80%] text-[15px] md:text-base leading-7", |
| msg.role === "user" |
| ? "bg-orange-600 text-white px-5 py-3 rounded-2xl rounded-tr-sm shadow-md" |
| : "text-gray-800 dark:text-gray-200 px-2 py-1 prose dark:prose-invert max-w-none" |
| )} |
| > |
| {msg.role === "bot" ? ( |
| typedMessages.has(msg.id) ? ( |
| <div className="markdown-content"> |
| <ReactMarkdown |
| remarkPlugins={[remarkGfm]} |
| components={markdownComponents} |
| > |
| {msg.content} |
| </ReactMarkdown> |
| </div> |
| ) : ( |
| <Typewriter |
| text={msg.content} |
| speed={15} |
| onTyping={() => scrollToBottom("auto")} |
| onComplete={() => |
| setTypedMessages((prev) => new Set([...prev, msg.id])) |
| } |
| /> |
| ) |
| ) : ( |
| msg.content |
| )} |
| </div> |
| </motion.div> |
| ))} |
| </AnimatePresence> |
| |
| {/* Loading State */} |
| {isLoading && ( |
| <motion.div |
| initial={{ opacity: 0, y: 10 }} |
| animate={{ opacity: 1, y: 0 }} |
| className="flex gap-4 md:gap-6" |
| > |
| <div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400 flex items-center justify-center shrink-0 mt-1"> |
| <Bot size={20} /> |
| </div> |
| <div className="flex flex-col gap-2 mt-2"> |
| <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm"> |
| <Loader2 size={16} className="animate-spin" /> |
| {content.thinking} |
| </div> |
| </div> |
| </motion.div> |
| )} |
| |
| {error && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| className="p-4 bg-red-50 text-red-600 border border-red-200 rounded-xl text-center" |
| > |
| <p>{error}</p> |
| </motion.div> |
| )} |
| <div ref={messagesEndRef} /> |
| </div> |
| </div> |
| |
| {/* STICKY Input Area */} |
| <div className="sticky bottom-0 left-0 w-full bg-background pb-6 pt-4 px-4 md:px-0 z-30"> |
| <div className="max-w-3xl mx-auto relative"> |
| {/* Shadow gradient top for nice effect */} |
| <div className="absolute -top-10 left-0 w-full h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" /> |
| |
| <motion.div |
| initial={{ y: 50, opacity: 0 }} |
| animate={{ y: 0, opacity: 1 }} |
| transition={{ delay: 0.5, type: "spring" }} |
| className="relative flex items-center bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 shadow-sm rounded-3xl p-2 transition-all focus-within:border-orange-500" |
| > |
| {/* Voice Button */} |
| <button |
| onClick={toggleListening} |
| className={classNames( |
| "p-3 rounded-full transition-all flex items-center justify-center mr-1 self-end mb-1", |
| isListening |
| ? "bg-red-500 text-white animate-pulse" |
| : "bg-transparent text-gray-400 hover:bg-gray-200 dark:hover:bg-neutral-800 hover:text-gray-600" |
| )} |
| > |
| {isListening ? <MicOff size={20} /> : <Mic size={20} />} |
| </button> |
| |
| <textarea |
| value={inputValue} |
| onChange={(e) => { |
| setInputValue(e.target.value); |
| e.target.style.height = "auto"; |
| e.target.style.height = `${e.target.scrollHeight}px`; |
| |
| // Toggle scrollbar only if height exceeds max (200px) |
| if (e.target.scrollHeight > 200) { |
| e.target.style.overflowY = "auto"; |
| } else { |
| e.target.style.overflowY = "hidden"; |
| } |
| }} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| if (window.innerWidth >= 768) { |
| e.preventDefault(); |
| handleSendMessage(inputValue); |
| } |
| } |
| }} |
| placeholder={content.placeholder} |
| disabled={isLoading} |
| rows={1} |
| style={{ maxHeight: "200px", overflowY: "hidden" }} |
| className="flex-1 bg-transparent border-none outline-none focus:ring-0 focus:outline-none rounded-2xl px-2 py-3 text-base text-gray-800 dark:text-gray-200 placeholder:text-gray-400 resize-none" |
| /> |
| <button |
| onClick={() => handleSendMessage(inputValue)} |
| disabled={!inputValue.trim() || isLoading} |
| className="p-3 bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:hover:bg-orange-600 text-white rounded-full transition-all shadow-sm transform hover:scale-105 active:scale-95 ml-2 self-end mb-1" |
| > |
| <Send size={18} /> |
| </button> |
| </motion.div> |
| <p className="text-center text-[10px] md:text-xs text-gray-400 mt-3 -mb-3 opacity-70"> |
| {content.powerBy} |
| </p> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|