diff --git a/apps/portal/package.json b/apps/portal/package.json index e51165fa21e..c4c98301222 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -25,9 +25,11 @@ "@next/mdx": "15.3.2", "@radix-ui/react-dialog": "1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.11", + "@radix-ui/react-popover": "^1.1.10", "@radix-ui/react-select": "^2.2.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.8", + "@radix-ui/react-tooltip": "1.2.3", "@tanstack/react-query": "5.74.4", "@tryghost/content-api": "^1.11.22", "class-variance-authority": "^0.7.1", diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index fd60cbe43f9..c43bb51ad8c 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -47,6 +47,7 @@ html { --success-text: 142.09 70.56% 35.29%; --warning-text: 38 92% 40%; --destructive-text: 357.15deg 100% 68.72%; + --nebula-pink-foreground: 321 90% 51%; /* Borders */ --border: 0 0% 85%; @@ -83,6 +84,7 @@ html { --destructive-text: 360 72% 55%; --success-text: 142 75% 50%; --inverted-foreground: 0 0% 0%; + --nebula-pink-foreground: 321 90% 51%; /* Borders */ --border: 0 0% 15%; @@ -159,3 +161,11 @@ button { .hide-scrollbar::-webkit-scrollbar { display: none; /* Safari and Chrome */ } + +.no-scrollbar { + scrollbar-width: none; /* Firefox */ +} + +.no-scrollbar::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 15943c55928..28457390249 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -6,6 +6,8 @@ import { Fira_Code, Inter } from "next/font/google"; import Script from "next/script"; import NextTopLoader from "nextjs-toploader"; import { StickyTopContainer } from "../components/Document/StickyTopContainer"; +import { CustomChatButton } from "../components/SiwaChat/CustomChatButton"; +import { examplePrompts } from "../components/SiwaChat/examplePrompts"; import { Banner } from "../components/others/Banner"; import { EnableSmoothScroll } from "../components/others/SmoothScroll"; import { PHProvider } from "../lib/posthog/Posthog"; @@ -67,6 +69,17 @@ export default function RootLayout({ /> + {/* Siwa AI Chat Widget Floating Button */} + +
{/* Note: Please change id as well when changing text or href so that new banner is shown to user even if user dismissed the older one */} diff --git a/apps/portal/src/components/SiwaChat/ChatBar.tsx b/apps/portal/src/components/SiwaChat/ChatBar.tsx new file mode 100644 index 00000000000..f77ca9d41f4 --- /dev/null +++ b/apps/portal/src/components/SiwaChat/ChatBar.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { ArrowUpIcon, CircleStopIcon } from "lucide-react"; +import { useState } from "react"; +import { cn } from "../../lib/utils"; +// NOTE: You will need to update these imports to match your portal's structure or stub them if not available +import { Button } from "../ui/button"; +import { AutoResizeTextarea } from "../ui/textarea"; +import type { NebulaUserMessage } from "./types"; + +// Define proper TypeScript interfaces +interface ChatContext { + chainId?: number; + contractAddress?: string; + functionName?: string; + parameters?: Record; + [key: string]: unknown; +} + +interface ConnectedWallet { + address: string; + walletId: string; + chainId?: number; + isActive?: boolean; +} + +// Props interfaces for better organization +interface MessageHandlingProps { + sendMessage: (message: NebulaUserMessage) => void; + prefillMessage?: string; + placeholder: string; +} + +interface ChatStateProps { + isChatStreaming: boolean; + abortChatStream: () => void; + isConnectingWallet: boolean; +} + +interface WalletProps { + connectedWallets: ConnectedWallet[]; + setActiveWallet: (wallet: ConnectedWallet) => void; +} + +interface ContextProps { + context: ChatContext | undefined; + setContext: (context: ChatContext | undefined) => void; + showContextSelector: boolean; +} + +interface UIProps { + className?: string; + onLoginClick?: () => void; +} + +// Combined props interface +interface ChatBarProps + extends MessageHandlingProps, + ChatStateProps, + Partial, + Partial, + UIProps {} + +export function ChatBar(props: ChatBarProps) { + const [message, setMessage] = useState(props.prefillMessage || ""); + + const handleSendMessage = () => { + if (message.trim() === "") return; + + const userMessage: NebulaUserMessage = { + role: "user", + content: [{ type: "text", text: message }], + }; + + props.sendMessage(userMessage); + setMessage(""); + }; + + return ( +
+
+ + +
+
+ ); +} + +// Updated MessageInput component +interface MessageInputProps { + message: string; + setMessage: (message: string) => void; + placeholder: string; + isChatStreaming: boolean; + onSend: () => void; +} + +function MessageInput({ + message, + setMessage, + placeholder, + isChatStreaming, + onSend, +}: MessageInputProps) { + return ( +
+ setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.shiftKey) return; + if (e.key === "Enter" && !isChatStreaming) { + e.preventDefault(); + onSend(); + } + }} + className="min-h-[60px] resize-none border-none bg-transparent pt-2 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0" + disabled={isChatStreaming} + /> +
+ ); +} + +// Updated ChatActions component +interface ChatActionsProps { + isChatStreaming: boolean; + isConnectingWallet: boolean; + abortChatStream: () => void; + onSendMessage: () => void; + canSend: boolean; +} + +function ChatActions({ + isChatStreaming, + isConnectingWallet, + abortChatStream, + onSendMessage, + canSend, +}: ChatActionsProps) { + return ( +
+
+
+ {isChatStreaming ? ( + + ) : ( + + )} +
+
+ ); +} + +// Decomposed component for stop button +interface StopButtonProps { + onStop: () => void; +} + +function StopButton({ onStop }: StopButtonProps) { + return ( + + ); +} + +// Decomposed component for send button +interface SendButtonProps { + onSend: () => void; + disabled: boolean; +} + +function SendButton({ onSend, disabled }: SendButtonProps) { + return ( + + ); +} diff --git a/apps/portal/src/components/SiwaChat/Chats.tsx b/apps/portal/src/components/SiwaChat/Chats.tsx new file mode 100644 index 00000000000..46d8a91237b --- /dev/null +++ b/apps/portal/src/components/SiwaChat/Chats.tsx @@ -0,0 +1,154 @@ +import { NebulaIcon } from "@/icons"; +import { useEffect, useRef } from "react"; +import { cn } from "../../lib/utils"; +import { ScrollShadow } from "../others/ScrollShadow/ScrollShadow"; +import type { NebulaUserMessageContent } from "./types"; + +export type ChatMessage = + | { + type: "user"; + content: NebulaUserMessageContent; + } + | { + texts: string[]; + type: "presence"; + } + | { + request_id: string | undefined; + text: string; + type: "assistant"; + }; + +export function Chats(props: { + messages: Array; + className?: string; + setEnableAutoScroll: (enable: boolean) => void; + enableAutoScroll: boolean; + useSmallText?: boolean; +}) { + const { messages, setEnableAutoScroll, enableAutoScroll } = props; + const scrollAnchorRef = useRef(null); + const chatContainerRef = useRef(null); + + useEffect(() => { + if (!enableAutoScroll || messages.length === 0) { + return; + } + scrollAnchorRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, enableAutoScroll]); + + useEffect(() => { + if (!enableAutoScroll) { + return; + } + const chatScrollContainer = + chatContainerRef.current?.querySelector("[data-scrollable]"); + if (!chatScrollContainer) { + return; + } + const disableScroll = () => { + setEnableAutoScroll(false); + chatScrollContainer.removeEventListener("mousedown", disableScroll); + chatScrollContainer.removeEventListener("wheel", disableScroll); + }; + chatScrollContainer.addEventListener("mousedown", disableScroll); + chatScrollContainer.addEventListener("wheel", disableScroll); + + return () => { + chatScrollContainer.removeEventListener("mousedown", disableScroll); + chatScrollContainer.removeEventListener("wheel", disableScroll); + }; + }, [setEnableAutoScroll, enableAutoScroll]); + + return ( +
+ +
+
+ {props.messages.map((message, index) => { + const shouldHideMessage = + message.type === "user" && + message.content.every((msg) => msg.type === "transaction"); + if (shouldHideMessage) { + return null; + } + + // Create a unique key based on message content and position + const messageKey = + message.type === "user" + ? `user-${index}-${message.content[0]?.type === "text" ? message.content[0].text.slice(0, 50) : "unknown"}` + : message.type === "assistant" + ? `assistant-${index}-${message.text?.slice(0, 50) || "empty"}` + : `${message.type}-${index}`; + + return ( +
+ +
+ ); + })} +
+
+
+ +
+ ); +} + +function RenderMessage(props: { + message: ChatMessage; +}) { + if (props.message.type === "user") { + return ( +
+ {props.message.content.map((msg, index) => { + if (msg.type === "text") { + // Create unique key based on content type and text + const contentKey = `${msg.type}-${msg.text.slice(0, 100)}-${index}`; + return ( +
+
+ {msg.text} +
+
+ ); + } + return null; + })} +
+ ); + } + if (props.message.type === "assistant") { + return ( +
+
+
+
+ +
+
+
+ + {props.message.text} + +
+
+
+ ); + } + return null; +} diff --git a/apps/portal/src/components/SiwaChat/CustomChatButton.tsx b/apps/portal/src/components/SiwaChat/CustomChatButton.tsx new file mode 100644 index 00000000000..f7b994fc193 --- /dev/null +++ b/apps/portal/src/components/SiwaChat/CustomChatButton.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { MessageCircleIcon, XIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import CustomChatContent from "./CustomChatContent"; + +interface CustomApiParams { + apiUrl?: string; + apiKey?: string; + timeout?: number; + retryAttempts?: number; + [key: string]: unknown; // Allow additional custom parameters +} + +export function CustomChatButton(props: { + isLoggedIn?: boolean; + networks: "mainnet" | "testnet" | "all" | null; + pageType: "chain" | "contract" | "support"; + label: string; + customApiParams: CustomApiParams; + examplePrompts: { title: string; message: string }[]; + authToken: string | undefined; + requireLogin?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const [hasBeenOpened, setHasBeenOpened] = useState(false); + const [isDismissed, _setIsDismissed] = useState(false); + const closeModal = useCallback(() => setIsOpen(false), []); + const ref = useRef(null); + + if (isDismissed) { + return null; + } + + return ( + <> + {/* Floating Button (hide when modal is open) */} + {!isOpen && ( + + )} + + {/* Popup/Modal */} +
+ {/* Header with close button */} +
+
+ + {props.label} +
+ +
+ {/* Chat Content */} +
+ {hasBeenOpened && isOpen && ( + + )} +
+
+ + ); +} diff --git a/apps/portal/src/components/SiwaChat/CustomChatContent.tsx b/apps/portal/src/components/SiwaChat/CustomChatContent.tsx new file mode 100644 index 00000000000..23e68a788f3 --- /dev/null +++ b/apps/portal/src/components/SiwaChat/CustomChatContent.tsx @@ -0,0 +1,214 @@ +"use client"; +import { NebulaIcon } from "@/icons"; +import { useCallback, useState } from "react"; +import { Button } from "../ui/button"; +import { ChatBar } from "./ChatBar"; +import { Chats } from "./Chats"; +import type { ChatMessage } from "./Chats"; +import type { ExamplePrompt } from "./examplePrompts"; +import type { NebulaUserMessage } from "./types"; + +export default function CustomChatContent(props: { + examplePrompts: ExamplePrompt[]; + networks: "mainnet" | "testnet" | "all" | null; + requireLogin?: boolean; +}) { + // No login required for portal + return ( + + ); +} + +function CustomChatContentLoggedIn(props: { + examplePrompts: ExamplePrompt[]; + networks: "mainnet" | "testnet" | "all" | null; +}) { + const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); + const [messages, setMessages] = useState>([]); + const [sessionId, setSessionId] = useState(undefined); + const [chatAbortController, setChatAbortController] = useState< + AbortController | undefined + >(); + const [isChatStreaming, setIsChatStreaming] = useState(false); + const [enableAutoScroll, setEnableAutoScroll] = useState(false); + + const handleSendMessage = useCallback( + async (userMessage: NebulaUserMessage) => { + const abortController = new AbortController(); + setUserHasSubmittedMessage(true); + setIsChatStreaming(true); + setEnableAutoScroll(true); + + setMessages((prev) => [ + ...prev, + { + type: "user", + content: userMessage.content, + }, + { + type: "presence", + texts: [], + }, + ]); + + const messageToSend = { + ...userMessage, + content: [...userMessage.content], + } as NebulaUserMessage; + + try { + setChatAbortController(abortController); + const payload = { + message: + messageToSend.content.find((x) => x.type === "text")?.text ?? "", + conversationId: sessionId, + }; + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + if (!apiUrl) { + throw new Error( + "API URL is not configured. Please set NEXT_PUBLIC_SIWA_URL environment variable.", + ); + } + const response = await fetch(`${apiUrl}/v1/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: abortController.signal, + }); + if (!response.ok) { + const errorText = await response + .text() + .catch(() => "No error details available"); + throw new Error( + `HTTP error! Status: ${response.status}. Details: ${errorText}`, + ); + } + const data = await response.json(); + if (!data || typeof data.data !== "string") { + throw new Error("Invalid response format from API"); + } + if (data.conversationId && data.conversationId !== sessionId) { + setSessionId(data.conversationId); + } + setMessages((prev) => [ + ...prev.slice(0, -1), + { + type: "assistant", + request_id: undefined, + text: data.data, + }, + ]); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + setMessages((prev) => [ + ...prev.slice(0, -1), + { + type: "assistant", + request_id: undefined, + text: `Sorry, something went wrong. ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ]); + } finally { + setIsChatStreaming(false); + setEnableAutoScroll(false); + } + }, + [sessionId], + ); + + const showEmptyState = !userHasSubmittedMessage && messages.length === 0; + return ( +
+ {showEmptyState ? ( + + ) : ( + + )} + {}} + showContextSelector={false} + connectedWallets={[]} + setActiveWallet={() => {}} + abortChatStream={() => { + chatAbortController?.abort(); + setChatAbortController(undefined); + setIsChatStreaming(false); + if (messages[messages.length - 1]?.type === "presence") { + setMessages((prev) => prev.slice(0, -1)); + } + }} + isChatStreaming={isChatStreaming} + prefillMessage={undefined} + sendMessage={handleSendMessage} + className="rounded-none border-x-0 border-b-0" + /> +
+ ); +} + +function EmptyStateChatPageContent(props: { + sendMessage: (message: NebulaUserMessage) => void; + examplePrompts: ExamplePrompt[]; +}) { + return ( +
+
+
+
+ +
+
+
+ +

+ How can I help you
+ today? +

+ +
+
+ {props.examplePrompts.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/apps/portal/src/components/SiwaChat/examplePrompts.ts b/apps/portal/src/components/SiwaChat/examplePrompts.ts new file mode 100644 index 00000000000..27b415d5e39 --- /dev/null +++ b/apps/portal/src/components/SiwaChat/examplePrompts.ts @@ -0,0 +1,30 @@ +export type ExamplePrompt = { + title: string; + message: string; + interceptedReply?: string; +}; + +export const examplePrompts: ExamplePrompt[] = [ + { + title: + "How do I add in-app wallet with sign in with google to my react app?", + message: + "How do I add in-app wallet with sign in with google to my react app?", + }, + { + title: "How do I send a transaction in Unity?", + message: "How do I send a transaction in Unity?", + }, + { + title: "What does this contract revert error mean?", + message: "What does this contract revert error mean?", + }, + { + title: "I see thirdweb support id in my console log, can you help me?", + message: "I see thirdweb support id in my console log, can you help me?", + }, + { + title: "Here is my code, can you tell me why I'm seeing this error?", + message: "Here is my code, can you tell me why I'm seeing this error?", + }, +]; diff --git a/apps/portal/src/components/SiwaChat/types.ts b/apps/portal/src/components/SiwaChat/types.ts new file mode 100644 index 00000000000..9cb49c5cbf6 --- /dev/null +++ b/apps/portal/src/components/SiwaChat/types.ts @@ -0,0 +1,67 @@ +type SessionContextFilter = { + chain_ids: string[] | null; + wallet_address: string | null; +}; + +type NebulaUserMessageContentItem = + | { + type: "text"; + text: string; + } + | { + type: "transaction"; + transaction_hash: string; + chain_id: number; + }; + +export type NebulaUserMessageContent = NebulaUserMessageContentItem[]; + +export type NebulaUserMessage = { + role: "user"; + content: NebulaUserMessageContent; +}; + +export type NebulaSessionHistoryMessage = + | { + role: "assistant" | "action"; + content: string; + timestamp: number; + } + | { + role: "user"; + content: NebulaUserMessageContent | string; + }; + +export type SessionInfo = { + id: string; + account_id: string; + modal_name: string; + can_execute: boolean; + created_at: string; + updated_at: string; + deleted_at: string | null; + archived_at: string | null; + history: Array | null; + title: string | null; + is_public: boolean | null; + context: SessionContextFilter | null; +}; + +export type UpdatedSessionInfo = { + title: string; + modal_name: string; + account_id: string; + context: SessionContextFilter | null; +}; + +export type DeletedSessionInfo = { + id: string; + deleted_at: string; +}; + +export type TruncatedSessionInfo = { + created_at: string; + id: string; + updated_at: string; + title: string | null; +}; diff --git a/apps/portal/src/components/others/ScrollShadow/ScrollShadow.tsx b/apps/portal/src/components/others/ScrollShadow/ScrollShadow.tsx index b9ebac181b6..932c909bc9d 100644 --- a/apps/portal/src/components/others/ScrollShadow/ScrollShadow.tsx +++ b/apps/portal/src/components/others/ScrollShadow/ScrollShadow.tsx @@ -1,7 +1,8 @@ "use client"; -import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../../../lib/useIsomorphicLayoutEffect"; +import { cn } from "../../../lib/utils"; import styles from "./ScrollShadow.module.css"; export function ScrollShadow(props: { @@ -19,7 +20,7 @@ export function ScrollShadow(props: { const shadowRightEl = useRef(null); const wrapperEl = useRef(null); - useEffect(() => { + useIsomorphicLayoutEffect(() => { const content = scrollableEl.current; const shadowTop = shadowTopEl.current; const shadowBottom = shadowBottomEl.current; @@ -139,10 +140,7 @@ export function ScrollShadow(props: { }} />
diff --git a/apps/portal/src/components/ui/button.tsx b/apps/portal/src/components/ui/button.tsx index 61614702b1c..f867d915d68 100644 --- a/apps/portal/src/components/ui/button.tsx +++ b/apps/portal/src/components/ui/button.tsx @@ -19,6 +19,7 @@ const buttonVariants = cva( "bg-secondary hover:bg-secondary/80 text-secondary-foreground ", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", + pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20", upsell: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200", }, diff --git a/apps/portal/src/components/ui/popover.tsx b/apps/portal/src/components/ui/popover.tsx new file mode 100644 index 00000000000..001b1630857 --- /dev/null +++ b/apps/portal/src/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/portal/src/components/ui/textarea.tsx b/apps/portal/src/components/ui/textarea.tsx new file mode 100644 index 00000000000..520e9db4f34 --- /dev/null +++ b/apps/portal/src/components/ui/textarea.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +