|
| 1 | +import type { ComponentProps } from "react"; |
| 2 | +import * as Dialog from "@radix-ui/react-dialog"; |
| 3 | +import * as ScrollArea from "@radix-ui/react-scroll-area"; |
| 4 | +import { |
| 5 | + PenLine, |
| 6 | + Trash2, |
| 7 | + X, |
| 8 | + ChevronLeft, |
| 9 | + ChevronRight, |
| 10 | + LogOut, |
| 11 | +} from "lucide-react"; |
| 12 | +import type { LucideIcon } from "lucide-react"; |
| 13 | +import { ChatInput } from "./chat-input"; |
| 14 | +import { ChatMessage } from "./chat-message"; |
| 15 | +import { ChatSidebar } from "./chat-sidebar"; |
| 16 | +import type { ChatWidgetProps } from "@aptos-labs/ai-chatbot-client"; |
| 17 | +import { useState, useRef, useEffect } from "react"; |
| 18 | +import { cn } from "utils/cn"; |
| 19 | + |
| 20 | +export interface ChatDialogProps extends ChatWidgetProps { |
| 21 | + open?: boolean; |
| 22 | + onOpenChange?: (open: boolean) => void; |
| 23 | + showTrigger?: boolean; |
| 24 | + user?: { |
| 25 | + displayName?: string | null; |
| 26 | + email?: string | null; |
| 27 | + photoURL?: string | null; |
| 28 | + } | null; |
| 29 | + onSignOut?: () => void; |
| 30 | +} |
| 31 | + |
| 32 | +const IconComponent = ({ |
| 33 | + icon: Icon, |
| 34 | + ...props |
| 35 | +}: { icon: LucideIcon } & ComponentProps<"svg">) => { |
| 36 | + return <Icon {...props} />; |
| 37 | +}; |
| 38 | + |
| 39 | +export function ChatDialog({ |
| 40 | + open, |
| 41 | + onOpenChange, |
| 42 | + messages = [], |
| 43 | + isLoading, |
| 44 | + isGenerating, |
| 45 | + isTyping, |
| 46 | + hasMoreMessages, |
| 47 | + onSendMessage, |
| 48 | + onStopGenerating, |
| 49 | + onLoadMore, |
| 50 | + onCopyMessage, |
| 51 | + onMessageFeedback, |
| 52 | + onNewChat, |
| 53 | + className, |
| 54 | + messageClassName, |
| 55 | + fastMode, |
| 56 | + showSidebar = true, |
| 57 | + showTrigger = true, |
| 58 | + chats = [], |
| 59 | + currentChatId, |
| 60 | + onSelectChat, |
| 61 | + onDeleteChat, |
| 62 | + onUpdateChatTitle, |
| 63 | + onToggleFastMode, |
| 64 | + user, |
| 65 | + onSignOut, |
| 66 | +}: ChatDialogProps) { |
| 67 | + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); |
| 68 | + const chatInputRef = useRef<HTMLTextAreaElement>(null); |
| 69 | + const viewportRef = useRef<HTMLDivElement>(null); |
| 70 | + |
| 71 | + const scrollToBottom = () => { |
| 72 | + if (viewportRef.current) { |
| 73 | + viewportRef.current.scrollTo({ |
| 74 | + top: viewportRef.current.scrollHeight, |
| 75 | + behavior: "smooth", |
| 76 | + }); |
| 77 | + } |
| 78 | + }; |
| 79 | + |
| 80 | + useEffect(() => { |
| 81 | + const timeoutId = setTimeout(scrollToBottom, 100); |
| 82 | + return () => clearTimeout(timeoutId); |
| 83 | + }, [messages, isTyping]); |
| 84 | + |
| 85 | + const handleNewChat = () => { |
| 86 | + onNewChat?.(); |
| 87 | + setTimeout(() => { |
| 88 | + chatInputRef.current?.focus(); |
| 89 | + }, 100); |
| 90 | + }; |
| 91 | + |
| 92 | + return ( |
| 93 | + <Dialog.Root open={open} onOpenChange={onOpenChange}> |
| 94 | + {showTrigger && ( |
| 95 | + <Dialog.Trigger asChild> |
| 96 | + <button className="flex items-center gap-2 text-sm font-medium text-text-primary hover:text-text-link"> |
| 97 | + <svg |
| 98 | + xmlns="http://www.w3.org/2000/svg" |
| 99 | + fill="none" |
| 100 | + viewBox="0 0 24 24" |
| 101 | + strokeWidth={1.5} |
| 102 | + stroke="currentColor" |
| 103 | + className="w-5 h-5" |
| 104 | + > |
| 105 | + <path |
| 106 | + strokeLinecap="round" |
| 107 | + strokeLinejoin="round" |
| 108 | + d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" |
| 109 | + /> |
| 110 | + </svg> |
| 111 | + Chat with AI |
| 112 | + </button> |
| 113 | + </Dialog.Trigger> |
| 114 | + )} |
| 115 | + <Dialog.Portal> |
| 116 | + <Dialog.Overlay className="fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" /> |
| 117 | + <Dialog.Content |
| 118 | + className={cn( |
| 119 | + "fixed z-[100000] flex flex-col overflow-hidden rounded-xl bg-[#0F0F0F] shadow-xl", |
| 120 | + "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2", |
| 121 | + "h-[85vh] w-[85vw] max-w-[1200px]", |
| 122 | + "data-[state=open]:animate-zoom-in data-[state=closed]:animate-zoom-out", |
| 123 | + className, |
| 124 | + )} |
| 125 | + style={ |
| 126 | + { |
| 127 | + "--header-height": "3.5rem", |
| 128 | + "--footer-height": "4.5rem", |
| 129 | + } as React.CSSProperties |
| 130 | + } |
| 131 | + aria-describedby="dialog-description" |
| 132 | + > |
| 133 | + {/* Header */} |
| 134 | + <div |
| 135 | + className="flex shrink-0 items-center justify-between border-b border-[#1F1F1F] px-4" |
| 136 | + style={{ height: "var(--header-height)" }} |
| 137 | + > |
| 138 | + <div className="flex items-center gap-4"> |
| 139 | + <div className="flex items-center gap-2"> |
| 140 | + <Dialog.Title className="text-lg font-medium text-white"> |
| 141 | + Ask AI |
| 142 | + </Dialog.Title> |
| 143 | + {showSidebar && user && ( |
| 144 | + <button |
| 145 | + onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)} |
| 146 | + className="rounded p-1 text-gray-400 hover:bg-[#1F1F1F] hover:text-white" |
| 147 | + > |
| 148 | + <IconComponent |
| 149 | + icon={isSidebarCollapsed ? ChevronRight : ChevronLeft} |
| 150 | + className="h-4 w-4" |
| 151 | + /> |
| 152 | + </button> |
| 153 | + )} |
| 154 | + </div> |
| 155 | + </div> |
| 156 | + <div className="flex items-center gap-4"> |
| 157 | + {user ? ( |
| 158 | + <> |
| 159 | + <div className="flex items-center gap-3 border-r border-[#1F1F1F] pr-4"> |
| 160 | + <div className="flex items-center gap-2"> |
| 161 | + <span className="text-sm text-gray-300"> |
| 162 | + {user.displayName || user.email} |
| 163 | + </span> |
| 164 | + </div> |
| 165 | + {onSignOut && ( |
| 166 | + <button |
| 167 | + onClick={onSignOut} |
| 168 | + className="rounded p-1.5 text-gray-400 hover:bg-[#1F1F1F] hover:text-white" |
| 169 | + title="Sign out" |
| 170 | + > |
| 171 | + <IconComponent icon={LogOut} className="h-4 w-4" /> |
| 172 | + </button> |
| 173 | + )} |
| 174 | + </div> |
| 175 | + <button |
| 176 | + onClick={handleNewChat} |
| 177 | + className="rounded-lg bg-white px-4 py-1.5 text-sm font-medium text-black hover:bg-gray-100" |
| 178 | + > |
| 179 | + <div className="flex items-center gap-2"> |
| 180 | + <IconComponent icon={PenLine} className="h-4 w-4" /> |
| 181 | + New chat |
| 182 | + </div> |
| 183 | + </button> |
| 184 | + {currentChatId && ( |
| 185 | + <button |
| 186 | + onClick={() => onDeleteChat?.(currentChatId)} |
| 187 | + className="rounded p-2 text-gray-400 hover:bg-[#1F1F1F] hover:text-white" |
| 188 | + > |
| 189 | + <IconComponent icon={Trash2} className="h-5 w-5" /> |
| 190 | + </button> |
| 191 | + )} |
| 192 | + </> |
| 193 | + ) : null} |
| 194 | + <Dialog.Close className="rounded p-2 text-gray-400 hover:bg-[#1F1F1F] hover:text-white"> |
| 195 | + <IconComponent icon={X} className="h-5 w-5" /> |
| 196 | + </Dialog.Close> |
| 197 | + </div> |
| 198 | + </div> |
| 199 | + |
| 200 | + <Dialog.Description id="dialog-description" className="sr-only"> |
| 201 | + Chat interface for interacting with Aptos AI assistant. Use this |
| 202 | + dialog to ask questions and get responses from the AI. |
| 203 | + </Dialog.Description> |
| 204 | + |
| 205 | + {/* Main Content */} |
| 206 | + <div className="flex min-h-0 flex-1"> |
| 207 | + {/* Sidebar */} |
| 208 | + {showSidebar && user && ( |
| 209 | + <ChatSidebar |
| 210 | + chats={chats} |
| 211 | + currentChatId={currentChatId || undefined} |
| 212 | + onSelectChat={onSelectChat} |
| 213 | + onDeleteChat={onDeleteChat} |
| 214 | + onUpdateChatTitle={onUpdateChatTitle} |
| 215 | + onNewChat={onNewChat} |
| 216 | + fastMode={fastMode} |
| 217 | + onToggleFastMode={onToggleFastMode} |
| 218 | + isCollapsed={isSidebarCollapsed} |
| 219 | + className="shrink-0 transition-all duration-200" |
| 220 | + /> |
| 221 | + )} |
| 222 | + |
| 223 | + {/* Chat Area */} |
| 224 | + <div className="flex min-h-0 flex-1 flex-col bg-black"> |
| 225 | + {/* Messages Area */} |
| 226 | + <div className="min-h-0 flex-1 overflow-hidden"> |
| 227 | + {!user ? ( |
| 228 | + <div className="flex h-full items-center justify-center"> |
| 229 | + <div className="text-center"> |
| 230 | + <h2 className="mb-4 text-xl font-semibold text-white"> |
| 231 | + Sign in to Start Chatting |
| 232 | + </h2> |
| 233 | + <button |
| 234 | + onClick={onSignOut} |
| 235 | + className="rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700" |
| 236 | + > |
| 237 | + Sign in with Google |
| 238 | + </button> |
| 239 | + </div> |
| 240 | + </div> |
| 241 | + ) : ( |
| 242 | + <ScrollArea.Root className="h-full"> |
| 243 | + <ScrollArea.Viewport |
| 244 | + ref={viewportRef} |
| 245 | + className="h-full w-full" |
| 246 | + > |
| 247 | + <div className="flex flex-col gap-4 p-4"> |
| 248 | + {hasMoreMessages && ( |
| 249 | + <button |
| 250 | + onClick={onLoadMore} |
| 251 | + className="text-sm text-gray-400 hover:text-white" |
| 252 | + > |
| 253 | + Load more |
| 254 | + </button> |
| 255 | + )} |
| 256 | + {messages.map((message) => ( |
| 257 | + <ChatMessage |
| 258 | + key={message.id} |
| 259 | + message={message} |
| 260 | + onCopy={() => onCopyMessage?.(message.id)} |
| 261 | + onFeedback={(feedback) => |
| 262 | + onMessageFeedback?.(message.id, feedback) |
| 263 | + } |
| 264 | + className={messageClassName} |
| 265 | + /> |
| 266 | + ))} |
| 267 | + {(isLoading || isTyping) && ( |
| 268 | + <div className="flex items-center text-gray-400"> |
| 269 | + <div className="animate-pulse">...</div> |
| 270 | + </div> |
| 271 | + )} |
| 272 | + </div> |
| 273 | + </ScrollArea.Viewport> |
| 274 | + <ScrollArea.Scrollbar orientation="vertical"> |
| 275 | + <ScrollArea.Thumb className="z-50 w-1.5 rounded-full bg-gray-700" /> |
| 276 | + </ScrollArea.Scrollbar> |
| 277 | + </ScrollArea.Root> |
| 278 | + )} |
| 279 | + </div> |
| 280 | + |
| 281 | + {/* Input Area */} |
| 282 | + {user && ( |
| 283 | + <div |
| 284 | + className="shrink-0 border-t border-[#1F1F1F] bg-[#0F0F0F] px-4" |
| 285 | + style={{ height: "var(--footer-height)" }} |
| 286 | + > |
| 287 | + <ChatInput |
| 288 | + ref={chatInputRef} |
| 289 | + onSend={onSendMessage} |
| 290 | + onStop={onStopGenerating} |
| 291 | + isLoading={isGenerating} |
| 292 | + className="h-full py-3" |
| 293 | + /> |
| 294 | + </div> |
| 295 | + )} |
| 296 | + </div> |
| 297 | + </div> |
| 298 | + </Dialog.Content> |
| 299 | + </Dialog.Portal> |
| 300 | + </Dialog.Root> |
| 301 | + ); |
| 302 | +} |
0 commit comments