diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx index f0043de7388..7c74579f324 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/layout.tsx @@ -8,6 +8,7 @@ import { getContractMetadata } from "thirdweb/extensions/common"; import { isAddress, isContractDeployed } from "thirdweb/utils"; import { shortenIfAddress } from "utils/usedapp-external"; import { NebulaChatButton } from "../../../../../nebula-app/(app)/components/FloatingChat/FloatingChat"; +import { examplePrompts } from "../../../../../nebula-app/(app)/data/examplePrompts"; import { getAuthTokenWalletAddress, getUserThirdwebClient, @@ -94,14 +95,6 @@ Users may be considering integrating the contract into their applications. Discu The following is the user's message:`; - const examplePrompts: string[] = [ - "What does this contract do?", - "What permissions or roles exist in this contract?", - "Which functions are used the most?", - "Has this contract been used recently?", - "Who are the largest holders/users of this?", - ]; - return ( ({ - title: prompt, - message: prompt, - }))} + examplePrompts={examplePrompts} /> {props.children} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx index d95e5d2032e..9530a403357 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/support/page.tsx @@ -1,6 +1,5 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; import { BookOpenIcon, ChevronRightIcon } from "lucide-react"; import type { Metadata } from "next"; import Image from "next/image"; @@ -10,7 +9,8 @@ import contractsIcon from "../../../../../public/assets/support/contracts.png"; import engineIcon from "../../../../../public/assets/support/engine.png"; import miscIcon from "../../../../../public/assets/support/misc.svg"; import connectIcon from "../../../../../public/assets/support/wallets.png"; -import { NebulaChatButton } from "../../../nebula-app/(app)/components/FloatingChat/FloatingChat"; +import { getTeams } from "../../../../@/api/team"; +import { CustomChatButton } from "../../../nebula-app/(app)/components/CustomChat/CustomChatButton"; import { getAuthToken, getAuthTokenWalletAddress, @@ -118,25 +118,22 @@ const HELP_PRODUCTS = [ }, ] as const; +export const siwaExamplePrompts = [ + "How do I add in-app wallet with sign in with google to my react app?", + "How do I send a transaction in Unity?", + "What does this contract revert error mean?", + "I see thirdweb support id in my console log, can you help me?", + "Here is my code, can you tell me why I'm seeing this error?", +]; + export default async function SupportPage() { const [authToken, accountAddress] = await Promise.all([ getAuthToken(), getAuthTokenWalletAddress(), ]); - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: undefined, - }); - - const supportPromptPrefix = - "You are a Customer Success Agent at thirdweb, assisting customers with blockchain and Web3-related issues. Use the following details to craft a professional, empathetic response: "; - const examplePrompts = [ - "ERC20 - Transfer Amount Exceeds Allowance", - "Replacement transaction underpriced / Replacement fee too low", - "Nonce too low: next nonce #, tx nonce #", - "Nonce too high", - ]; + const teams = await getTeams(); + const teamId = teams?.[0]?.id ?? undefined; return (
@@ -157,22 +154,16 @@ export default async function SupportPage() { team.

- ({ - title: prompt, - message: prompt, - }))} + label="Ask AI for support" + examplePrompts={siwaExamplePrompts} + authToken={authToken || undefined} + teamId={teamId} + clientId={undefined} />
{props.children}
+
+ +
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/layout.tsx index 524059cef0c..aeda1c9d704 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/layout.tsx @@ -3,7 +3,9 @@ import { getTeams } from "@/api/team"; import { SidebarProvider } from "@/components/ui/sidebar"; import { AnnouncementBanner } from "components/notices/AnnouncementBanner"; import { redirect } from "next/navigation"; +import { siwaExamplePrompts } from "../../../(dashboard)/support/page"; import { getClientThirdwebClient } from "../../../../../@/constants/thirdweb-client.client"; +import { CustomChatButton } from "../../../../nebula-app/(app)/components/CustomChat/CustomChatButton"; import { getValidAccount } from "../../../account/settings/getAccount"; import { getAuthToken, @@ -78,6 +80,19 @@ export default async function ProjectLayout(props: { {props.children} +
+ +
); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/layout.tsx index eba7709d06a..ed5f8e79cd5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/layout.tsx @@ -5,6 +5,7 @@ import { ArrowRightIcon } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { Suspense } from "react"; +import { getAuthToken } from "../../api/lib/getAuthToken"; import { EnsureValidConnectedWalletLoginServer } from "../../components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer"; import { isTeamOnboardingComplete } from "../../login/onboarding/isOnboardingRequired"; import { SaveLastVisitedTeamPage } from "../components/last-visited-page/SaveLastVisitedPage"; @@ -18,12 +19,17 @@ export default async function RootTeamLayout(props: { params: Promise<{ team_slug: string }>; }) { const { team_slug } = await props.params; + const authToken = await getAuthToken(); const team = await getTeamBySlug(team_slug).catch(() => null); if (!team) { redirect("/team"); } + if (!authToken) { + redirect("/login"); + } + if (!isTeamOnboardingComplete(team)) { redirect(`/get-started/team/${team.slug}`); } diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx index 699b69f2325..bbf73828657 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx @@ -68,6 +68,7 @@ export function ChatBar(props: { isConnectingWallet: boolean; allowImageUpload: boolean; onLoginClick: undefined | (() => void); + placeholder: string; }) { const [message, setMessage] = useState(props.prefillMessage || ""); const selectedChainIds = props.context?.chainIds?.map((x) => Number(x)) || []; @@ -142,7 +143,7 @@ export function ChatBar(props: {
setMessage(e.target.value)} onKeyDown={(e) => { diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx index 4bca9787c17..6f57961bd79 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx @@ -339,6 +339,7 @@ export function ChatPageContent(props: { {messages.length > 0 && ( {}} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx index cc06e959f65..0f0fac8b955 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx @@ -227,6 +227,7 @@ function Variant(props: { }) { return ( {}} client={storybookThirdwebClient} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx index 484c0e0c0d7..76ac13d49f9 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx @@ -1,9 +1,11 @@ import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; import { cn } from "@/lib/utils"; import { MarkdownRenderer } from "components/contract-components/published-contract/markdown-renderer"; -import { AlertCircleIcon } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { AlertCircleIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import type { ThirdwebClient } from "thirdweb"; +import { Button } from "../../../../@/components/ui/button"; +import { useTrack } from "../../../../hooks/analytics/useTrack"; import type { NebulaSwapData } from "../api/chat"; import type { NebulaUserMessage, NebulaUserMessageContent } from "../api/types"; import { NebulaIcon } from "../icons/NebulaIcon"; @@ -72,6 +74,7 @@ export function Chats(props: { enableAutoScroll: boolean; useSmallText?: boolean; sendMessage: (message: NebulaUserMessage) => void; + teamId: string | undefined; }) { const { messages, setEnableAutoScroll, enableAutoScroll } = props; const scrollAnchorRef = useRef(null); @@ -153,6 +156,7 @@ export function Chats(props: { nextMessage={props.messages[index + 1]} authToken={props.authToken} sessionId={props.sessionId} + teamId={props.teamId} />
); @@ -172,6 +176,7 @@ function RenderMessage(props: { sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; authToken: string; + teamId: string | undefined; sessionId: string | undefined; }) { const { message } = props; @@ -224,6 +229,41 @@ function RenderMessage(props: { ); } + // Feedback for assistant messages + if (props.message.type === "assistant") { + return ( +
+
+ {/* Left Icon */} +
+
+ +
+
+ {/* Right Message */} +
+ + + + +
+
+
+ ); + } + return (
{/* Left Icon */} @@ -422,3 +462,81 @@ function StyledMarkdownRenderer(props: { /> ); } + +function FeedbackButtons({ + sessionId, + authToken, + teamId, +}: { + sessionId: string | undefined; + authToken: string; + teamId: string | undefined; +}) { + const [, setFeedback] = useState<1 | -1 | null>(null); + const [loading, setLoading] = useState(false); + const [thankYou, setThankYou] = useState(false); + const trackEvent = useTrack(); + + async function sendFeedback(rating: 1 | -1) { + setLoading(true); + try { + trackEvent({ + category: "siwa", + action: "submit-feedback", + rating: rating === 1 ? "good" : "bad", + sessionId, + teamId, + }); + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + await fetch(`${apiUrl}/v1/chat/feedback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + ...(teamId ? { "x-team-id": teamId } : {}), + }, + body: JSON.stringify({ + conversationId: sessionId, + feedbackRating: rating, + }), + }); + setFeedback(rating); + setThankYou(true); + } catch { + // TODO handle error + } finally { + setLoading(false); + } + } + + if (thankYou) { + return ( +
+ Thank you for your feedback! +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatButton.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatButton.tsx new file mode 100644 index 00000000000..72801f5a5b9 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatButton.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { MessageCircleIcon, XIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { createThirdwebClient } from "thirdweb"; +import { NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID } from "../../../../../@/constants/public-envs"; +import { useTrack } from "../../../../../hooks/analytics/useTrack"; +import CustomChatContent from "./CustomChatContent"; + +// Create a thirdweb client for the chat functionality +const client = createThirdwebClient({ + clientId: NET_PUBLIC_DASHBOARD_THIRDWEB_CLIENT_ID, +}); + +export function CustomChatButton(props: { + isLoggedIn: boolean; + networks: "mainnet" | "testnet" | "all" | null; + isFloating: boolean; + pageType: "chain" | "contract" | "support"; + label: string; + examplePrompts: string[]; + authToken: string | undefined; + teamId: string | undefined; + clientId: string | undefined; + requireLogin?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const [hasBeenOpened, setHasBeenOpened] = useState(false); + const closeModal = useCallback(() => setIsOpen(false), []); + const ref = useRef(null); + const trackEvent = useTrack(); + + return ( + <> + {/* Inline Button (not floating) */} + + + {/* Popup/Modal */} +
+ {/* Header with close button */} +
+
+ + {props.label} +
+ +
+ {/* Chat Content */} +
+ {hasBeenOpened && isOpen && ( + ({ + message: prompt, + title: prompt, + }))} + networks={props.networks} + requireLogin={props.requireLogin} + /> + )} +
+
+ + ); +} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx new file mode 100644 index 00000000000..6eb1b3767a0 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx @@ -0,0 +1,284 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { useTrack } from "hooks/analytics/useTrack"; +import { ArrowRightIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useCallback, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { useActiveWalletConnectionStatus } from "thirdweb/react"; +import type { NebulaContext } from "../../api/chat"; +import type { NebulaUserMessage } from "../../api/types"; +import type { ExamplePrompt } from "../../data/examplePrompts"; +import { NebulaIcon } from "../../icons/NebulaIcon"; +import { ChatBar } from "../ChatBar"; +import { Chats } from "../Chats"; +import type { ChatMessage } from "../Chats"; + +export default function CustomChatContent(props: { + authToken: string | undefined; + teamId: string | undefined; + clientId: string | undefined; + client: ThirdwebClient; + examplePrompts: ExamplePrompt[]; + networks: NebulaContext["networks"]; + requireLogin?: boolean; +}) { + if (props.requireLogin !== false && !props.authToken) { + return ; + } + + return ( + + ); +} + +function CustomChatContentLoggedIn(props: { + authToken: string; + teamId: string | undefined; + clientId: string | undefined; + client: ThirdwebClient; + examplePrompts: ExamplePrompt[]; + networks: NebulaContext["networks"]; +}) { + const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); + const [messages, setMessages] = useState>([]); + // sessionId is initially undefined, will be set to conversationId from API after first response + const [sessionId, setSessionId] = useState(undefined); + const [chatAbortController, setChatAbortController] = useState< + AbortController | undefined + >(); + const trackEvent = useTrack(); + const [isChatStreaming, setIsChatStreaming] = useState(false); + const [enableAutoScroll, setEnableAutoScroll] = useState(false); + const connectionStatus = useActiveWalletConnectionStatus(); + + const handleSendMessage = useCallback( + async (userMessage: NebulaUserMessage) => { + const abortController = new AbortController(); + setUserHasSubmittedMessage(true); + setIsChatStreaming(true); + setEnableAutoScroll(true); + + const textMessage = userMessage.content.find((x) => x.type === "text"); + + trackEvent({ + category: "siwa", + action: "send-message", + message: textMessage?.text, + sessionId: sessionId, + }); + + setMessages((prev) => [ + ...prev, + { + type: "user", + content: userMessage.content, + }, + // instant loading indicator feedback to user + { + type: "presence", + texts: [], + }, + ]); + + // if this is first message, set the message prefix + // deep clone `userMessage` to avoid mutating the original message, its a pretty small object so JSON.parse is fine + const messageToSend = JSON.parse( + JSON.stringify(userMessage), + ) as NebulaUserMessage; + + try { + setChatAbortController(abortController); + // --- Custom API call --- + const payload = { + message: + messageToSend.content.find((x) => x.type === "text")?.text ?? "", + conversationId: sessionId, + }; + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + const response = await fetch(`${apiUrl}/v1/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${props.authToken}`, + ...(props.teamId ? { "x-team-id": props.teamId } : {}), + ...(props.clientId ? { "x-client-id": props.clientId } : {}), + }, + body: JSON.stringify(payload), + signal: abortController.signal, + }); + const data = await response.json(); + // If the response contains a conversationId, set it as the sessionId for future messages + if (data.conversationId && data.conversationId !== sessionId) { + setSessionId(data.conversationId); + } + setMessages((prev) => [ + ...prev.slice(0, -1), // remove presence indicator + { + 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); + } + }, + [props.authToken, props.clientId, props.teamId, sessionId, trackEvent], + ); + + const showEmptyState = !userHasSubmittedMessage && messages.length === 0; + return ( +
+ {showEmptyState ? ( + + ) : ( + + )} + {}} + showContextSelector={false} + connectedWallets={[]} + setActiveWallet={() => {}} + abortChatStream={() => { + chatAbortController?.abort(); + setChatAbortController(undefined); + setIsChatStreaming(false); + // if last message is presence, remove it + 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" + allowImageUpload={false} + /> +
+ ); +} + +function LoggedOutStateChatContent() { + const pathname = usePathname(); + return ( +
+
+
+
+ +
+
+
+ +

+ How can I help you
+ today? +

+ +
+

+ Sign in to use AI Assistant +

+
+ + +
+ ); +} + +function EmptyStateChatPageContent(props: { + sendMessage: (message: NebulaUserMessage) => void; + examplePrompts: { title: string; message: string }[]; +}) { + return ( +
+
+
+
+ +
+
+
+ +

+ How can I help you
+ today? +

+ +
+
+ {props.examplePrompts.map((prompt) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx index 011c803c80f..d23f9e02ba3 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx @@ -43,6 +43,7 @@ export function EmptyStateChatPageContent(props: {
void; -}) { +function ExamplePrompt(props: { label: string; onClick: () => void }) { return (