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 (
+
+ sendFeedback(-1)}
+ disabled={loading}
+ aria-label="Thumbs down"
+ >
+
+
+ sendFeedback(1)}
+ disabled={loading}
+ aria-label="Thumbs up"
+ >
+
+
+
+ );
+}
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) */}
+ {
+ trackEvent({
+ category: "siwa",
+ action: "open-chat",
+ });
+ setIsOpen(true);
+ setHasBeenOpened(true);
+ }}
+ variant="default"
+ className="gap-2 rounded-full shadow-lg"
+ >
+
+ {props.label}
+
+
+ {/* 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
+
+
+
+
+
+ Sign in
+
+
+
+
+ );
+}
+
+function EmptyStateChatPageContent(props: {
+ sendMessage: (message: NebulaUserMessage) => void;
+ examplePrompts: { title: string; message: string }[];
+}) {
+ return (
+
+
+
+
+ How can I help you
+ today?
+
+
+
+
+ {props.examplePrompts.map((prompt) => (
+
+ props.sendMessage({
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: prompt.message,
+ },
+ ],
+ })
+ }
+ disabled={false}
+ >
+ {prompt.title}
+
+ ))}
+
+
+ );
+}
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 (
) : (
)}
void;
-}) {
+function ExamplePromptButton(props: { label: string; onClick: () => void }) {
return (