Spaces:
Running
Running
| import React, { | |
| useCallback, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState, | |
| } from "react"; | |
| import { | |
| TrashIcon, | |
| SparklesIcon, | |
| PaperClipIcon, | |
| MicrophoneIcon, | |
| PaperAirplaneIcon, | |
| ArrowPathIcon, | |
| DocumentDuplicateIcon, | |
| ChatBubbleLeftRightIcon, | |
| CommandLineIcon, | |
| } from "@heroicons/react/24/outline"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| // Internal hooks/types | |
| import { useChat } from "./useChat"; | |
| import type { ThreadMeta } from "./threads"; | |
| // --- Constants & Config --- | |
| const APP_TITLE = "Krishna's Digital Twin"; | |
| const BOT_AVATAR = | |
| "https://api.dicebear.com/9.x/bottts-neutral/svg?seed=Krishna1&backgroundColor=6366f1"; // Cleaner 3D-ish Robot | |
| /* | |
| const DID_YOU_KNOW = [ | |
| "Krishna achieved a 3.95 GPA during his M.S. at Virginia Tech.", | |
| "Krishna built an agent that automates Android UI tasks with 80%+ accuracy.", | |
| "Krishna optimized genomic ETL pipelines reducing runtime by 70%.", | |
| "Krishna specializes in building autonomous agents and RAG systems.", | |
| ]; | |
| */ | |
| const SUGGESTIONS = [ | |
| { | |
| title: "Summarize Experience", | |
| text: "Give me a 90-second intro to Krishna Vamsi Dhulipalla—recent work, top strengths, and impact.", | |
| icon: "✨", | |
| }, | |
| { | |
| title: "Download Resume", | |
| text: "Share Krishna’s latest resume and provide a download link.", | |
| icon: "📄", | |
| }, | |
| { | |
| title: "Capabilities", | |
| text: "What tools and actions can you perform for me?", | |
| icon: "🛠️", | |
| }, | |
| { | |
| title: "Schedule Meeting", | |
| text: "Schedule a 30-minute meeting with Krishna next week.", | |
| icon: "📅", | |
| }, | |
| ]; | |
| /* | |
| function DidYouKnowRotator() { | |
| const [index, setIndex] = useState(0); | |
| useEffect(() => { | |
| const interval = setInterval(() => { | |
| setIndex((prev) => (prev + 1) % DID_YOU_KNOW.length); | |
| }, 5000); // Rotate every 5 seconds | |
| return () => clearInterval(interval); | |
| }, []); | |
| return ( | |
| <div className="mt-8 p-4 rounded-xl bg-white/5 border border-white/5 max-w-lg mx-auto text-center animate-in fade-in slide-in-from-bottom-2 duration-700"> | |
| <p className="text-[10px] uppercase tracking-widest text-zinc-500 font-semibold mb-2"> | |
| Did you know? | |
| </p> | |
| <p | |
| key={index} | |
| className="text-sm text-zinc-300 italic min-h-[20px] transition-all duration-500" | |
| > | |
| "{DID_YOU_KNOW[index]}" | |
| </p> | |
| </div> | |
| ); | |
| } | |
| */ | |
| // --- Helper: Date Grouping for Sidebar --- | |
| function groupThreadsByDate(threads: ThreadMeta[]) { | |
| const today = new Date(); | |
| const yesterday = new Date(); | |
| yesterday.setDate(yesterday.getDate() - 1); | |
| const groups: Record<string, ThreadMeta[]> = { | |
| Today: [], | |
| Yesterday: [], | |
| "Previous 7 Days": [], | |
| Older: [], | |
| }; | |
| threads.forEach((t) => { | |
| const d = new Date(t.lastAt); | |
| if (d.toDateString() === today.toDateString()) { | |
| groups["Today"].push(t); | |
| } else if (d.toDateString() === yesterday.toDateString()) { | |
| groups["Yesterday"].push(t); | |
| } else if (d.getTime() > today.getTime() - 7 * 24 * 60 * 60 * 1000) { | |
| groups["Previous 7 Days"].push(t); | |
| } else { | |
| groups["Older"].push(t); | |
| } | |
| }); | |
| return groups; | |
| } | |
| // --- Main Component --- | |
| export default function App() { | |
| const { | |
| threads, | |
| active, | |
| messages, | |
| setActiveThread, | |
| newChat, | |
| clearChat, | |
| deleteThread, | |
| send, | |
| isStreaming, | |
| hasFirstToken, | |
| } = useChat(); | |
| const [input, setInput] = useState(""); | |
| const bottomRef = useRef<HTMLDivElement | null>(null); | |
| const inputRef = useRef<HTMLTextAreaElement | null>(null); | |
| const prevThreadId = useRef<string | null>(null); | |
| // --- Voice Input Logic --- | |
| const [isListening, setIsListening] = useState(false); | |
| const recognitionRef = useRef<any>(null); | |
| useEffect(() => { | |
| const SpeechRecognition = | |
| (window as any).SpeechRecognition || | |
| (window as any).webkitSpeechRecognition; | |
| if (SpeechRecognition) { | |
| recognitionRef.current = new SpeechRecognition(); | |
| recognitionRef.current.continuous = false; | |
| recognitionRef.current.interimResults = false; | |
| recognitionRef.current.lang = "en-US"; | |
| recognitionRef.current.onresult = (event: any) => { | |
| const transcript = event.results[0][0].transcript; | |
| setInput((prev) => (prev ? prev + " " + transcript : transcript)); | |
| setIsListening(false); | |
| }; | |
| recognitionRef.current.onerror = (event: any) => { | |
| console.error("Speech recognition error", event.error); | |
| setIsListening(false); | |
| }; | |
| recognitionRef.current.onend = () => { | |
| setIsListening(false); | |
| }; | |
| } | |
| }, []); | |
| const toggleListening = useCallback(() => { | |
| if (!recognitionRef.current) { | |
| alert("Browser does not support Speech Recognition"); | |
| return; | |
| } | |
| if (isListening) { | |
| recognitionRef.current.stop(); | |
| setIsListening(false); | |
| } else { | |
| recognitionRef.current.start(); | |
| setIsListening(true); | |
| } | |
| }, [isListening]); | |
| // Auto-scroll logic | |
| useEffect(() => { | |
| const currentThreadId = active?.id ?? null; | |
| if (currentThreadId !== prevThreadId.current) { | |
| prevThreadId.current = currentThreadId; | |
| bottomRef.current?.scrollIntoView({ behavior: "auto" }); | |
| } else { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| } | |
| }, [messages, active?.id]); | |
| const sendMessage = useCallback(() => { | |
| const text = input.trim(); | |
| if (!text || isStreaming) return; | |
| send(text); | |
| setInput(""); | |
| }, [input, isStreaming, send]); | |
| const handleKeyDown = useCallback( | |
| (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }, | |
| [sendMessage] | |
| ); | |
| const groupedThreads = useMemo(() => groupThreadsByDate(threads), [threads]); | |
| return ( | |
| <div className="flex h-screen w-screen bg-[#05050A] text-zinc-100 font-sans selection:bg-indigo-500/30"> | |
| {/* Background Ambience */} | |
| <div className="fixed inset-0 z-0 pointer-events-none"> | |
| <div className="absolute top-[-10%] right-[-5%] w-[500px] h-[500px] bg-indigo-900/20 rounded-full blur-[128px]" /> | |
| <div className="absolute bottom-[-10%] left-[-10%] w-[600px] h-[600px] bg-purple-900/10 rounded-full blur-[128px]" /> | |
| </div> | |
| {/* --- Sidebar --- */} | |
| <aside className="hidden md:flex flex-col w-[280px] z-10 bg-zinc-950/40 backdrop-blur-xl border-r border-white/5 transition-all"> | |
| {/* Header */} | |
| <div className="p-4 border-b border-white/5"> | |
| <button | |
| onClick={newChat} | |
| className="group flex items-center gap-3 w-full px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 transition-all duration-200" | |
| > | |
| <div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 group-hover:text-indigo-300"> | |
| <SparklesIcon className="w-5 h-5" /> | |
| </div> | |
| <span className="text-sm font-medium text-zinc-200">New Chat</span> | |
| </button> | |
| </div> | |
| {/* History List */} | |
| <div className="flex-1 overflow-y-auto p-3 space-y-6 scrollbar-thin scrollbar-thumb-zinc-800"> | |
| {Object.entries(groupedThreads).map(([label, group]) => { | |
| if (group.length === 0) return null; | |
| return ( | |
| <div key={label}> | |
| <h3 className="px-3 mb-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500"> | |
| {label} | |
| </h3> | |
| <div className="space-y-0.5"> | |
| {group.map((t) => ( | |
| <div | |
| key={t.id} | |
| onClick={() => setActiveThread(t)} | |
| className={`group relative flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${ | |
| active?.id === t.id | |
| ? "bg-white/10 text-zinc-100" | |
| : "text-zinc-400 hover:bg-white/5 hover:text-zinc-200" | |
| }`} | |
| > | |
| <span | |
| className="text-sm flex-1 line-clamp-1 break-all" | |
| title={t.title || "New Conversation"} | |
| > | |
| {t.title || "New Conversation"} | |
| </span> | |
| {/* Delete Action */} | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (window.confirm("Delete thread?")) | |
| deleteThread(t.id); | |
| }} | |
| className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-red-500/20 text-zinc-500 hover:text-red-400 transition-all" | |
| > | |
| <TrashIcon className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* Sidebar Footer */} | |
| <div className="p-4 border-t border-white/5"> | |
| <a | |
| href="https://github.com/krishna-dhulipalla/LangGraph_ChatBot" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors" | |
| > | |
| <CommandLineIcon className="w-4 h-4" /> | |
| <span>View Source Code</span> | |
| </a> | |
| </div> | |
| </aside> | |
| {/* --- Main Chat Area --- */} | |
| <main className="relative z-10 flex-1 flex flex-col h-full overflow-hidden"> | |
| {/* Top Navigation */} | |
| <header className="h-16 flex items-center justify-between px-6 border-b border-white/5 bg-zinc-950/20 backdrop-blur-sm"> | |
| <div className="flex items-center gap-3"> | |
| <div className="relative"> | |
| <img | |
| src={BOT_AVATAR} | |
| alt="Bot" | |
| className="w-9 h-9 rounded-full ring-2 ring-indigo-500/20 shadow-lg shadow-indigo-500/10" | |
| /> | |
| <span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 border-2 border-[#05050A] rounded-full"></span> | |
| </div> | |
| <div> | |
| <h1 className="text-sm font-semibold text-zinc-100"> | |
| {APP_TITLE} | |
| </h1> | |
| <p className="text-[10px] text-zinc-400 flex items-center gap-1"> | |
| <span className="inline-block w-1 h-1 rounded-full bg-indigo-500 animate-pulse" /> | |
| Online & Ready | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={clearChat} | |
| className="p-2 text-zinc-400 hover:text-zinc-200 hover:bg-white/5 rounded-full transition-all" | |
| title="Clear Chat" | |
| > | |
| <ArrowPathIcon className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </header> | |
| {/* Chat Stream */} | |
| <div className="flex-1 overflow-y-auto p-4 md:p-8 scroll-smooth"> | |
| {messages.length === 0 ? ( | |
| <div className="h-full flex flex-col items-center justify-center max-w-2xl mx-auto animate-in fade-in duration-500"> | |
| <div className="mb-8 p-4 rounded-full bg-white/5 ring-1 ring-white/10 shadow-2xl shadow-indigo-500/10"> | |
| <ChatBubbleLeftRightIcon className="w-10 h-10 text-indigo-400" /> | |
| </div> | |
| <h2 className="text-2xl font-semibold text-transparent bg-clip-text bg-gradient-to-br from-zinc-100 to-zinc-500 mb-2"> | |
| How can I help you today? | |
| </h2> | |
| <p className="text-zinc-400 text-sm mb-6 max-w-md text-center"> | |
| I'm Krishna's digital twin. Ask me about his architecture | |
| skills, recent projects, or schedule a meeting. | |
| </p> | |
| {/* <DidYouKnowRotator /> */} | |
| {/* <div className="h-8"></div> */} | |
| {/* Suggestions Grid */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full"> | |
| {SUGGESTIONS.map((s, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => { | |
| // Just send immediately without prefilling input state visually | |
| if (!isStreaming) send(s.text); | |
| }} | |
| className="group flex items-start gap-4 p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/5 hover:border-white/10 text-left transition-all hover:-translate-y-0.5 hover:shadow-lg hover:shadow-indigo-500/10" | |
| > | |
| <span className="text-xl grayscale group-hover:grayscale-0 transition-all"> | |
| {s.icon} | |
| </span> | |
| <div> | |
| <div className="text-sm font-medium text-zinc-200 group-hover:text-indigo-300 transition-colors"> | |
| {s.title} | |
| </div> | |
| <div className="text-xs text-zinc-500 mt-1 line-clamp-1 group-hover:text-zinc-400 transition-colors"> | |
| {s.text} | |
| </div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="max-w-3xl mx-auto space-y-6"> | |
| {messages.map((m, idx) => ( | |
| <Bubble | |
| key={m.id || idx} | |
| message={m} | |
| isStreaming={isStreaming && idx === messages.length - 1} | |
| /> | |
| ))} | |
| {/* Streaming Indicator */} | |
| {isStreaming && !hasFirstToken && ( | |
| <div className="flex gap-1 ml-14"> | |
| <span className="w-2 h-2 rounded-full bg-zinc-600 animate-bounce" /> | |
| <span className="w-2 h-2 rounded-full bg-zinc-600 animate-bounce [animation-delay:0.1s]" /> | |
| <span className="w-2 h-2 rounded-full bg-zinc-600 animate-bounce [animation-delay:0.2s]" /> | |
| </div> | |
| )} | |
| <div ref={bottomRef} className="h-4" /> | |
| </div> | |
| )} | |
| </div> | |
| {/* --- Input Area --- */} | |
| <div className="p-4 md:p-6 pb-8"> | |
| <div className="max-w-3xl mx-auto relative"> | |
| <div className="relative flex flex-col gap-2 p-2 rounded-3xl bg-zinc-900/50 backdrop-blur-xl border border-white/10 shadow-2xl focus-within:ring-1 focus-within:ring-indigo-500/50 transition-all"> | |
| <textarea | |
| ref={inputRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Type a message..." | |
| className="w-full bg-transparent text-zinc-100 placeholder-zinc-500 px-4 py-3 min-h-[50px] max-h-[200px] resize-none outline-none text-[15px] leading-relaxed scrollbar-hide" | |
| rows={1} | |
| /> | |
| {/* Input Actions Bar */} | |
| <div className="flex items-center justify-between px-2 pb-1"> | |
| <div className="flex items-center gap-1"> | |
| <button | |
| className="cursor-not-allowed opacity-50 p-2 rounded-xl text-zinc-400 hover:text-zinc-200 hover:bg-white/5 transition-colors" | |
| title="Attach file (coming soon)" | |
| > | |
| <PaperClipIcon className="w-5 h-5" /> | |
| </button> | |
| <button | |
| onClick={toggleListening} | |
| className={`p-2 rounded-xl transition-all duration-300 ${ | |
| isListening | |
| ? "text-red-400 bg-red-500/10 animate-pulse ring-1 ring-red-500/20" | |
| : "text-zinc-400 hover:text-zinc-200 hover:bg-white/5" | |
| }`} | |
| title={isListening ? "Stop Listening" : "Voice Input"} | |
| > | |
| <MicrophoneIcon className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <button | |
| onClick={sendMessage} | |
| disabled={!input.trim() || isStreaming} | |
| className={`p-2 rounded-xl flex items-center gap-2 transition-all ${ | |
| input.trim() && !isStreaming | |
| ? "bg-indigo-600 text-white shadow-lg shadow-indigo-500/20 hover:bg-indigo-500" | |
| : "bg-zinc-800 text-zinc-500 cursor-not-allowed" | |
| }`} | |
| > | |
| {isStreaming ? ( | |
| <span className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full animate-spin" /> | |
| ) : ( | |
| <PaperAirplaneIcon className="w-5 h-5 -ml-0.5 transform -rotate-45 translate-x-0.5" /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="text-center mt-3"> | |
| <p className="text-[10px] text-zinc-600"> | |
| AI can make mistakes. Please verify important information. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| // --- Message Bubble Component --- | |
| function Bubble({ | |
| message, | |
| isStreaming, | |
| }: { | |
| message: any; | |
| isStreaming: boolean; | |
| }) { | |
| const isUser = message.role === "user"; | |
| return ( | |
| <div | |
| className={`flex gap-4 ${isUser ? "justify-end" : "justify-start group"}`} | |
| > | |
| {!isUser && ( | |
| <div className="shrink-0 flex flex-col gap-2"> | |
| <img | |
| src={BOT_AVATAR} | |
| alt="AI" | |
| className="w-8 h-8 rounded-full ring-1 ring-white/10" | |
| /> | |
| </div> | |
| )} | |
| <div | |
| className={`relative max-w-[85%] md:max-w-[75%] rounded-2xl px-5 py-3.5 shadow-sm text-[15px] leading-7 ${ | |
| isUser | |
| ? "bg-gradient-to-br from-indigo-600 to-violet-600 text-white rounded-br-sm shadow-md shadow-indigo-500/10" | |
| : "bg-white/5 border border-white/5 text-zinc-100 rounded-bl-sm backdrop-blur-md" | |
| }`} | |
| > | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={{ | |
| a: ({ ...props }) => ( | |
| <a | |
| {...props} | |
| className="text-blue-400 hover:underline" | |
| target="_blank" | |
| /> | |
| ), | |
| code: ({ inline, className, children, ...props }: any) => { | |
| if (inline) | |
| return ( | |
| <code | |
| className="bg-white/10 px-1 py-0.5 rounded font-mono text-sm" | |
| {...props} | |
| > | |
| {children} | |
| </code> | |
| ); | |
| return ( | |
| <pre className="bg-zinc-950/50 p-3 rounded-xl border border-white/5 overflow-x-auto my-2 text-sm"> | |
| <code className={className} {...props}> | |
| {children} | |
| </code> | |
| </pre> | |
| ); | |
| }, | |
| ul: (props) => ( | |
| <ul | |
| className="list-disc list-inside ml-2 space-y-1 my-2" | |
| {...props} | |
| /> | |
| ), | |
| ol: (props) => ( | |
| <ol | |
| className="list-decimal list-inside ml-2 space-y-1 my-2" | |
| {...props} | |
| /> | |
| ), | |
| p: (props) => <p className="mb-2 last:mb-0" {...props} />, | |
| }} | |
| > | |
| {message.content} | |
| </ReactMarkdown> | |
| {/* Actions for Assistant */} | |
| {!isUser && !isStreaming && ( | |
| <div className="absolute -bottom-6 left-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2"> | |
| <ActionButton | |
| icon={<DocumentDuplicateIcon className="w-3.5 h-3.5" />} | |
| label="Copy" | |
| onClick={() => navigator.clipboard.writeText(message.content)} | |
| /> | |
| <ActionButton | |
| icon={<ArrowPathIcon className="w-3.5 h-3.5" />} | |
| label="Regenerate" | |
| onClick={() => {}} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ActionButton({ | |
| icon, | |
| label, | |
| onClick, | |
| }: { | |
| icon: any; | |
| label: string; | |
| onClick: () => void; | |
| }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className="flex items-center gap-1 text-[10px] text-zinc-500 hover:text-zinc-300 bg-white/5 px-2 py-1 rounded-md transition-colors" | |
| > | |
| {icon} {label} | |
| </button> | |
| ); | |
| } | |