| 'use client'; |
|
|
| import React, { useState, useRef, useEffect } from 'react'; |
| import { StopCircle, CornerDownRight, Settings2, Menu } from 'lucide-react'; |
| import { ChatMessage, MessageProps } from './ChatMessage'; |
| import { Sidebar } from './Sidebar'; |
| import { AnimatePresence } from 'framer-motion'; |
| import { ApiService } from '@/lib/api'; |
|
|
| export default function Chat() { |
| const [messages, setMessages] = useState<MessageProps[]>([]); |
| const [input, setInput] = useState(''); |
| const [isLoading, setIsLoading] = useState(false); |
| const [sessionId, setSessionId] = useState<string | null>(null); |
| const [isEnhanced, setIsEnhanced] = useState(true); |
| const [isSidebarOpen, setIsSidebarOpen] = useState(true); |
| const [sessionHistory, setSessionHistory] = useState<Array<{ id: string, preview: string, timestamp: number }>>([]); |
|
|
| const messagesEndRef = useRef<HTMLDivElement>(null); |
| const textareaRef = useRef<HTMLTextAreaElement>(null); |
|
|
| const scrollToBottom = () => { |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); |
| }; |
|
|
| useEffect(() => { |
| scrollToBottom(); |
| }, [messages, isLoading]); |
|
|
| |
| useEffect(() => { |
| const stored = localStorage.getItem('nebula_history'); |
| if (stored) { |
| try { |
| setSessionHistory(JSON.parse(stored)); |
| } catch { |
| console.error("Could not parse history"); |
| } |
| } |
| }, []); |
|
|
| const saveSessionToHistory = (id: string, preview: string) => { |
| setSessionHistory(prev => { |
| const filtered = prev.filter(s => s.id !== id); |
| const newList = [{ id, preview, timestamp: Date.now() }, ...filtered]; |
| localStorage.setItem('nebula_history', JSON.stringify(newList)); |
| return newList; |
| }); |
| }; |
|
|
| const loadSession = async (id: string) => { |
| setIsLoading(true); |
| setSessionId(id); |
| localStorage.setItem('nebula_session_id', id); |
| try { |
| const history = await ApiService.getHistory(id); |
| const formattedHistory: MessageProps[] = []; |
| history.forEach((h: { user_message: string; ai_response: string }, i: number) => { |
| formattedHistory.push({ id: `user-${i}`, role: 'user', content: h.user_message }); |
| formattedHistory.push({ id: `bot-${i}`, role: 'bot', content: h.ai_response }); |
| }); |
|
|
| if (formattedHistory.length === 0) { |
| formattedHistory.push({ id: 'welcome', role: 'bot', content: 'Hello! I am Nexus AI. How can I help you today?' }); |
| } |
| setMessages(formattedHistory); |
| } catch (e) { |
| console.error(e); |
| } finally { |
| setIsLoading(false); |
| if (window.innerWidth < 768) setIsSidebarOpen(false); |
| } |
| }; |
|
|
| const handleNewChat = async () => { |
| setIsLoading(true); |
| try { |
| const newId = await ApiService.createSession(); |
| setSessionId(newId); |
| localStorage.setItem('nebula_session_id', newId as string); |
| setMessages([{ id: 'welcome', role: 'bot', content: 'Hello! I am Nexus AI. How can I help you today?', isNew: true }]); |
| if (window.innerWidth < 768) setIsSidebarOpen(false); |
| } catch (e) { |
| console.error(e); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| const initSession = async () => { |
| let storedSessionId = localStorage.getItem('nebula_session_id'); |
| if (!storedSessionId) { |
| try { |
| storedSessionId = await ApiService.createSession() as string; |
| localStorage.setItem('nebula_session_id', storedSessionId); |
| } catch { |
| console.error("Failed to init session."); |
| return; |
| } |
| } |
| loadSession(storedSessionId as string); |
| }; |
| initSession(); |
| }, []); |
|
|
| const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| setInput(e.target.value); |
| if (textareaRef.current) { |
| textareaRef.current.style.height = 'auto'; |
| textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; |
| } |
| }; |
|
|
| const handleSend = async () => { |
| if (!input.trim() || isLoading || !sessionId) return; |
|
|
| const userMsg: MessageProps = { |
| id: Date.now().toString(), |
| role: 'user', |
| content: input.trim(), |
| }; |
|
|
| const currentInputText = input.trim(); |
| saveSessionToHistory(sessionId, currentInputText.substring(0, 40)); |
|
|
| setMessages((prev) => [...prev, userMsg]); |
| setInput(''); |
| setIsLoading(true); |
|
|
| if (textareaRef.current) { |
| textareaRef.current.style.height = 'auto'; |
| } |
|
|
| try { |
| const reply = await ApiService.sendMessage(currentInputText, sessionId, isEnhanced); |
| const botMsg: MessageProps = { |
| id: (Date.now() + 1).toString(), |
| role: 'bot', |
| content: reply, |
| isNew: true, |
| }; |
| setMessages((prev) => [...prev, botMsg]); |
| } catch (error) { |
| console.error('Error sending message:', error); |
| const errorMsg: MessageProps = { id: (Date.now() + 1).toString(), role: 'bot', content: 'Error sending message. Please try again.', isNew: true }; |
| setMessages((prev) => [...prev, errorMsg]); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| handleSend(); |
| } |
| }; |
|
|
| return ( |
| <div className="flex h-screen w-full bg-background text-foreground overflow-hidden font-sans"> |
| <Sidebar |
| isOpen={isSidebarOpen} |
| setIsOpen={setIsSidebarOpen} |
| onNewChat={handleNewChat} |
| sessions={sessionHistory} |
| currentSessionId={sessionId} |
| onSelectSession={loadSession} |
| /> |
| |
| <div className="flex-1 flex flex-col h-full relative min-w-0 transition-all"> |
| {/* Header Navbar */} |
| <header className="absolute top-0 w-full z-10 px-4 py-3 flex items-center justify-between pointer-events-none anti-gravity-header"> |
| <div className="flex items-center gap-2 pointer-events-auto"> |
| {!isSidebarOpen && ( |
| <button |
| onClick={() => setIsSidebarOpen(true)} |
| className="p-2.5 hover:bg-white/10 rounded-full transition-transform active:scale-95 text-zinc-400" |
| > |
| <Menu size={20} /> |
| </button> |
| )} |
| <h1 className="text-xl font-medium tracking-tight ml-2 text-slate-200">Nexus AI</h1> |
| </div> |
| |
| <button |
| onClick={() => setIsEnhanced(!isEnhanced)} |
| className={`pointer-events-auto flex items-center gap-2 text-sm px-4 py-2 rounded-full transition-all active:scale-95 border ${isEnhanced ? 'bg-primary/20 border-primary text-white shadow-[0_0_15px_rgba(139,92,246,0.3)]' : 'bg-transparent border-transparent text-gray-400 hover:bg-white/5'}`} |
| > |
| <Settings2 size={16} /> |
| <span className="hidden sm:inline font-medium">{isEnhanced ? "Enhanced" : "Basic"}</span> |
| </button> |
| </header> |
| |
| {/* Main Scrollable Chat Area */} |
| <div className="flex-1 overflow-y-auto scroll-smooth pt-[80px] pb-[160px] custom-scrollbar"> |
| <div className="max-w-3xl mx-auto w-full px-4 sm:px-6"> |
| {messages.length === 1 && ( |
| <div className="flex flex-col items-center justify-center mt-32 mb-16 text-center animate-fade-in"> |
| <div className="w-20 h-20 mb-6 bg-white/5 rounded-full flex items-center justify-center backdrop-blur-sm border border-white/10 shadow-[0_0_30px_rgba(139,92,246,0.15)]"> |
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" className="text-primary drop-shadow-[0_0_15px_rgba(139,92,246,0.5)]"> |
| <path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" /> |
| </svg> |
| </div> |
| <h1 className="text-4xl sm:text-5xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-slate-200 via-primary to-slate-200 mb-4 tracking-tight"> |
| Hello, how can I help? |
| </h1> |
| </div> |
| )} |
| |
| <AnimatePresence initial={false}> |
| {messages.map((message) => ( |
| <ChatMessage key={message.id} message={message} /> |
| ))} |
| </AnimatePresence> |
| |
| {isLoading && ( |
| <div className="flex w-full mb-8 justify-start"> |
| <div className="flex gap-4 flex-row items-start"> |
| <div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mt-1 bg-transparent border border-white/5"> |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-primary animate-spin-slow"> |
| <path d="M12 2L14.809 9.19098L22 12L14.809 14.809L12 22L9.19098 14.809L2 12L9.19098 9.19098L12 2Z" fill="currentColor" /> |
| </svg> |
| </div> |
| <div className="anti-gravity-panel px-5 py-3 rounded-[24px] rounded-bl-sm flex items-center h-[48px] mt-1"> |
| <div className="flex gap-1.5"> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> |
| <span className="w-1.5 h-1.5 bg-primary/80 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> |
| </div> |
| </div> |
| </div> |
| </div> |
| )} |
| <div ref={messagesEndRef} className="h-4" /> |
| </div> |
| </div> |
| |
| {/* Bottom Floating Input Area */} |
| <div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6 pb-8 bg-gradient-to-t from-background via-background/90 to-transparent pointer-events-none"> |
| <div className="max-w-3xl mx-auto w-full relative pointer-events-auto"> |
| <div className="relative anti-gravity-panel rounded-[32px] transition-all flex items-end focus-within:ring-1 focus-within:ring-primary/50"> |
| <textarea |
| ref={textareaRef} |
| value={input} |
| onChange={handleInput} |
| onKeyDown={handleKeyDown} |
| placeholder="Enter a prompt here..." |
| className="w-full bg-transparent text-slate-200 placeholder:text-slate-500 px-6 py-4 resize-none outline-none text-[16px] leading-[1.65] max-h-[200px]" |
| rows={1} |
| /> |
| |
| <div className="p-2 sm:p-2.5 shrink-0 flex items-center pr-2.5 mb-1"> |
| {isLoading ? ( |
| <button disabled className="p-2 text-slate-500"> |
| <StopCircle size={22} className="animate-pulse" /> |
| </button> |
| ) : ( |
| <button |
| onClick={handleSend} |
| disabled={!input.trim()} |
| className={`p-2.5 rounded-full transition-all active:scale-90 flex items-center justify-center |
| ${!input.trim() |
| ? 'bg-transparent text-slate-600 cursor-not-allowed' |
| : 'bg-primary text-white shadow-[0_0_15px_rgba(139,92,246,0.4)] hover:shadow-[0_0_20px_rgba(139,92,246,0.6)] hover:bg-primary-hover' |
| }`} |
| > |
| <CornerDownRight size={20} className="stroke-[2.5]" /> |
| </button> |
| )} |
| </div> |
| </div> |
| <p className="text-center text-[11px] text-slate-400/60 mt-4 font-medium tracking-wide w-full px-4"> |
| Nexus AI answers can be inaccurate. Please double-check information. |
| </p> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|