From 1f6c03e30f3320478a24be74b6023bbf882ce431 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Mon, 8 Sep 2025 14:57:38 +1200 Subject: [PATCH] thirdweb AI inside dashboard --- apps/dashboard/package.json | 1 + apps/dashboard/src/@/actions/proxies.ts | 1 + .../blocks/full-width-sidebar-layout.tsx | 2 - .../src/@/components/chat/ChatBar.tsx | 2 +- .../@/components/chat/CustomChatButton.tsx | 5 +- .../@/components/ui/image-upload-button.tsx | 45 ++ apps/dashboard/src/@/constants/public-envs.ts | 3 + apps/dashboard/src/@/storybook/stubs.ts | 29 + .../(chain)/components/server/products.ts | 4 +- .../(sidebar)/ai/analytics/page.tsx | 102 +++ .../[project_slug]/(sidebar)/ai/api/chat.ts | 290 +++++++ .../(sidebar)/ai/api/feedback.ts | 28 + .../(sidebar)/ai/api/fetchWithAuthToken.ts | 62 ++ .../(sidebar)/ai/api/session.ts | 123 +++ .../[project_slug]/(sidebar)/ai/api/types.ts | 121 +++ .../(sidebar)/ai/chat/[session_id]/page.tsx | 65 ++ .../chat/history/ChatHistoryPage.stories.tsx | 92 +++ .../ai/chat/history/ChatHistoryPage.tsx | 243 ++++++ .../(sidebar)/ai/chat/history/page.tsx | 25 + .../AssetsSection/AssetsSection.stories.tsx | 101 +++ .../AssetsSection/AssetsSection.tsx | 254 ++++++ .../(sidebar)/ai/components/ChatBar.tsx | 720 +++++++++++++++++ .../ai/components/ChatPageContent.tsx | 754 ++++++++++++++++++ .../ai/components/ChatPageLayout.stories.tsx | 70 ++ .../ai/components/ChatPageLayout.tsx | 46 ++ .../(sidebar)/ai/components/ChatSidebar.tsx | 257 ++++++ .../ai/components/ChatSidebarLink.tsx | 96 +++ .../ai/components/Chatbar.stories.tsx | 195 +++++ .../(sidebar)/ai/components/Chats.stories.tsx | 239 ++++++ .../(sidebar)/ai/components/Chats.tsx | 423 ++++++++++ .../EmptyStateChatPageContent.stories.tsx | 51 ++ .../components/EmptyStateChatPageContent.tsx | 167 ++++ .../ExecuteTransactionCard.stories.tsx | 79 ++ .../ai/components/ExecuteTransactionCard.tsx | 164 ++++ .../ai/components/MessageActions.tsx | 126 +++ .../ai/components/NebulaConnectButton.tsx | 94 +++ .../(sidebar)/ai/components/NebulaImage.tsx | 126 +++ .../ai/components/NebulaMobileNav.tsx | 72 ++ .../Reasoning/Reasoning.stories.tsx | 34 + .../ai/components/Reasoning/Reasoning.tsx | 62 ++ .../ai/components/Swap/SwapCards.stories.tsx | 141 ++++ .../ai/components/Swap/SwapCards.tsx | 280 +++++++ .../(sidebar)/ai/components/Swap/common.tsx | 170 ++++ .../TransactionsSection.stories.tsx | 153 ++++ .../TransactionsSection.tsx | 210 +++++ .../(sidebar)/ai/data/examplePrompts.ts | 108 +++ .../ai/hooks/useSessionsWithLocalOverrides.ts | 25 + .../[project_slug]/(sidebar)/ai/page.tsx | 112 +-- .../[project_slug]/(sidebar)/ai/stores.ts | 7 + .../[project_slug]/(sidebar)/layout.tsx | 1 + packages/service-utils/src/core/services.ts | 2 +- pnpm-lock.yaml | 25 +- 52 files changed, 6507 insertions(+), 100 deletions(-) create mode 100644 apps/dashboard/src/@/components/ui/image-upload-button.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/analytics/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/feedback.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/fetchWithAuthToken.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/session.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/[session_id]/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/page.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chatbar.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/MessageActions.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaConnectButton.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/common.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.stories.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/hooks/useSessionsWithLocalOverrides.ts create mode 100644 apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/stores.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index d8e299df326..1de553dde2c 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -35,6 +35,7 @@ "compare-versions": "^6.1.0", "date-fns": "4.1.0", "fast-xml-parser": "^5.2.5", + "fetch-event-stream": "0.1.5", "fuse.js": "7.1.0", "input-otp": "^1.4.1", "ioredis": "^5.6.1", diff --git a/apps/dashboard/src/@/actions/proxies.ts b/apps/dashboard/src/@/actions/proxies.ts index 0e1dd79dfb3..b9c4fab51d1 100644 --- a/apps/dashboard/src/@/actions/proxies.ts +++ b/apps/dashboard/src/@/actions/proxies.ts @@ -14,6 +14,7 @@ type ProxyActionParams = { body?: string; headers?: Record; parseAsText?: boolean; + signal?: AbortSignal; }; type ProxyActionResult = diff --git a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx index 48b996b37f7..0054ac769cc 100644 --- a/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx +++ b/apps/dashboard/src/@/components/blocks/full-width-sidebar-layout.tsx @@ -3,7 +3,6 @@ import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; -import { AppFooter } from "@/components/footers/app-footer"; import { Collapsible, CollapsibleContent, @@ -91,7 +90,6 @@ export function FullWidthSidebarLayout(props: {
{children}
- ); diff --git a/apps/dashboard/src/@/components/chat/ChatBar.tsx b/apps/dashboard/src/@/components/chat/ChatBar.tsx index 6f3794e56c6..24b39307843 100644 --- a/apps/dashboard/src/@/components/chat/ChatBar.tsx +++ b/apps/dashboard/src/@/components/chat/ChatBar.tsx @@ -87,7 +87,7 @@ export function ChatBar(props: {

- Get access to image uploads by signing in to Nebula + Get access to image uploads by signing in to thirdweb

+ +
+ ); +} diff --git a/apps/dashboard/src/@/constants/public-envs.ts b/apps/dashboard/src/@/constants/public-envs.ts index f90758c0122..1a791d352f4 100644 --- a/apps/dashboard/src/@/constants/public-envs.ts +++ b/apps/dashboard/src/@/constants/public-envs.ts @@ -29,3 +29,6 @@ export const NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET = export const NEXT_PUBLIC_DEMO_ENGINE_URL = process.env.NEXT_PUBLIC_DEMO_ENGINE_URL || ""; + +export const NEXT_PUBLIC_THIRDWEB_AI_HOST = + process.env.NEXT_PUBLIC_THIRDWEB_AI_HOST || "https://nebula-api.thirdweb.com"; diff --git a/apps/dashboard/src/@/storybook/stubs.ts b/apps/dashboard/src/@/storybook/stubs.ts index d7b0124f445..b05d6f85a37 100644 --- a/apps/dashboard/src/@/storybook/stubs.ts +++ b/apps/dashboard/src/@/storybook/stubs.ts @@ -279,3 +279,32 @@ export function newAccountStub(overrides?: Partial): Account { ...overrides, }; } + +export function randomLorem(length: number) { + const loremWords = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + ]; + + return Array.from({ length }, () => { + const randomIndex = Math.floor(Math.random() * loremWords.length); + return loremWords[randomIndex]; + }).join(" "); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/products.ts b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/products.ts index 708a934e4e4..b840871b1e6 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/products.ts +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/components/server/products.ts @@ -62,8 +62,8 @@ export const products = [ description: "The most powerful AI for interacting with the blockchain", icon: NebulaIcon, id: "nebula", - link: "https://thirdweb.com/nebula", - name: "Nebula", + link: "https://thirdweb.com/ai", + name: "thirdweb AI", }, ] satisfies Array<{ name: string; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/analytics/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/analytics/page.tsx new file mode 100644 index 00000000000..2f818455eec --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/analytics/page.tsx @@ -0,0 +1,102 @@ +import { redirect } from "next/navigation"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import { getAuthToken } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import type { DurationId } from "@/components/analytics/date-range-selector"; +import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; +import { ProjectPage } from "@/components/blocks/project-page/project-page"; +import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; +import { NebulaIcon } from "@/icons/NebulaIcon"; +import { getFiltersFromSearchParams } from "@/lib/time"; +import { loginRedirect } from "@/utils/redirects"; +import { AiAnalytics } from "./chart"; +import { AiSummary } from "./chart/Summary"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; + searchParams: Promise<{ + from?: string; + to?: string; + type?: string; + interval?: string; + }>; +}) { + const [searchParams, params] = await Promise.all([ + props.searchParams, + props.params, + ]); + + const { team_slug, project_slug } = params; + + const [project, authToken] = await Promise.all([ + getProject(team_slug, project_slug), + getAuthToken(), + ]); + + if (!authToken) { + loginRedirect(`/team/${team_slug}/${project_slug}/ai`); + } + + if (!project) { + redirect(`/team/${team_slug}`); + } + + const client = getClientThirdwebClient({ + jwt: authToken, + teamId: project.teamId, + }); + + const defaultRange = "last-30" as DurationId; + const { range, interval } = getFiltersFromSearchParams({ + defaultRange, + from: searchParams.from, + interval: searchParams.interval, + to: searchParams.to, + }); + + return ( + + +
+ + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts new file mode 100644 index 00000000000..0b4e0732412 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/chat.ts @@ -0,0 +1,290 @@ +import { stream } from "fetch-event-stream"; +import type { Project } from "@/api/project/projects"; +import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; +import type { + NebulaContext, + NebulaSwapData, + NebulaTxData, + NebulaUserMessage, +} from "./types"; + +export async function promptNebula(params: { + message: NebulaUserMessage; + sessionId: string; + authToken: string; + handleStream: (res: ChatStreamedResponse) => void; + abortController: AbortController; + context: undefined | NebulaContext; + project: Project; +}) { + const body: Record = { + messages: [params.message], + session_id: params.sessionId, + stream: true, + }; + + if (params.context) { + body.context = { + chain_ids: params.context.chainIds || [], + networks: params.context.networks, + wallet_address: params.context.walletAddress, + }; + } + + try { + const events = await stream(`${NEXT_PUBLIC_THIRDWEB_AI_HOST}/chat`, { + body: JSON.stringify(body), + headers: { + Authorization: `Bearer ${params.authToken}`, + "x-team-id": params.project.teamId, + "x-client-id": params.project.publishableKey, + "Content-Type": "application/json", + }, + method: "POST", + signal: params.abortController.signal, + }); + + for await (const _event of events) { + if (!_event.data) { + continue; + } + + const event = _event as ChatStreamedEvent; + + switch (event.event) { + case "delta": { + params.handleStream({ + data: { + v: JSON.parse(event.data).v, + }, + event: "delta", + }); + break; + } + + case "presence": { + params.handleStream({ + data: JSON.parse(event.data), + event: "presence", + }); + break; + } + + case "image": { + const data = JSON.parse(event.data) as { + data: { + width: number; + height: number; + url: string; + }; + request_id: string; + }; + + params.handleStream({ + data: data.data, + event: "image", + request_id: data.request_id, + }); + break; + } + + case "action": { + const data = JSON.parse(event.data); + + if (data.type === "sign_transaction") { + try { + const parsedTxData = data.data as NebulaTxData; + params.handleStream({ + data: parsedTxData, + event: "action", + request_id: data.request_id, + type: "sign_transaction", + }); + } catch (e) { + console.error("failed to parse action data", e, { event }); + } + } + + if (data.type === "sign_swap") { + try { + const swapData = data.data as NebulaSwapData; + params.handleStream({ + data: swapData, + event: "action", + request_id: data.request_id, + type: "sign_swap", + }); + } catch (e) { + console.error("failed to parse action data", e, { event }); + } + } + + break; + } + + case "error": { + const data = JSON.parse(event.data) as { + code: number; + error: { + message: string; + }; + }; + + params.handleStream({ + data: { + code: data.code, + errorMessage: data.error.message, + }, + event: "error", + }); + break; + } + + case "init": { + const data = JSON.parse(event.data); + params.handleStream({ + data: { + request_id: data.request_id, + session_id: data.session_id, + }, + event: "init", + }); + break; + } + + case "context": { + const data = JSON.parse(event.data) as { + data: string; + request_id: string; + session_id: string; + }; + + const contextData = JSON.parse(data.data) as { + wallet_address: string; + chain_ids: number[]; + networks: NebulaContext["networks"]; + }; + + params.handleStream({ + data: contextData, + event: "context", + }); + break; + } + + case "ping": { + break; + } + + default: { + console.warn("unhandled event", event); + } + } + } + } catch (error) { + console.error("failed to stream events", error); + params.handleStream({ + data: { + code: 500, + errorMessage: `Failed to stream events: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + event: "error", + }); + params.abortController.abort(); + } +} + +type ChatStreamedResponse = + | { + event: "init"; + data: { + session_id: string; + request_id: string; + }; + } + | { + event: "presence"; + data: { + session_id: string; + request_id: string; + source: "user" | "reviewer" | (string & {}); + data: string; + }; + } + | { + event: "delta"; + data: { + v: string; + }; + } + | { + event: "action"; + type: "sign_transaction"; + data: NebulaTxData; + request_id: string; + } + | { + event: "action"; + type: "sign_swap"; + data: NebulaSwapData; + request_id: string; + } + | { + event: "image"; + data: { + width: number; + height: number; + url: string; + }; + request_id: string; + } + | { + event: "context"; + data: { + wallet_address: string; + chain_ids: number[]; + networks: NebulaContext["networks"]; + }; + } + | { + event: "error"; + data: { + code: number; + errorMessage: string; + }; + }; + +type ChatStreamedEvent = + | { + event: "init"; + data: string; + } + | { + event: "presence"; + data: string; + } + | { + event: "delta"; + data: string; + } + | { + event: "image"; + data: string; + } + | { + event: "action"; + type: "sign_transaction" | "sign_swap"; + data: string; + } + | { + event: "context"; + data: string; + } + | { + event: "error"; + data: string; + } + | { + event: "ping"; + data: string; + }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/feedback.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/feedback.ts new file mode 100644 index 00000000000..cff3c28a815 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/feedback.ts @@ -0,0 +1,28 @@ +"use server"; + +import type { Project } from "@/api/project/projects"; +import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; +import { fetchWithAuthToken } from "./fetchWithAuthToken"; + +export async function submitFeedback(params: { + project: Project; + sessionId: string; + requestId: string; + rating: "good" | "bad" | "neutral"; +}) { + const res = await fetchWithAuthToken({ + body: { + feedback_rating: + params.rating === "good" ? 1 : params.rating === "bad" ? -1 : 0, + request_id: params.requestId, + session_id: params.sessionId, + }, + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/feedback`, + method: "POST", + project: params.project, + }); + + if (!res.ok) { + throw new Error("Failed to submit feedback"); + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/fetchWithAuthToken.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/fetchWithAuthToken.ts new file mode 100644 index 00000000000..37a4f2b3e88 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/fetchWithAuthToken.ts @@ -0,0 +1,62 @@ +"use server"; + +import { getAuthToken } from "@/api/auth-token"; +import type { Project } from "@/api/project/projects"; + +type FetchWithKeyOptions = { + endpoint: string; + project: Project; + timeout?: number; +} & ( + | { + method: "POST" | "PUT"; + body: Record; + } + | { + method: "GET" | "DELETE"; + } +); + +export async function fetchWithAuthToken(options: FetchWithKeyOptions) { + const timeout = options.timeout || 30000; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const authToken = await getAuthToken(); + if (!authToken) { + throw new Error("No auth token found"); + } + const response = await fetch(options.endpoint, { + body: "body" in options ? JSON.stringify(options.body) : undefined, + headers: { + Accept: "application/json", + Authorization: `Bearer ${authToken}`, + "x-team-id": options.project.teamId, + "x-client-id": options.project.publishableKey, + "Content-Type": "application/json", + }, + method: options.method, + signal: controller.signal, + }); + + if (!response.ok) { + if (response.status === 504) { + throw new Error("Request timed out. Please try again."); + } + + const data = await response.text(); + throw new Error(`HTTP error! status: ${response.status}: ${data}`); + } + + return response; + } catch (error: unknown) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Request timed out. Please try again."); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/session.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/session.ts new file mode 100644 index 00000000000..b9f958bc4f6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/session.ts @@ -0,0 +1,123 @@ +"use server"; + +import type { Project } from "@/api/project/projects"; +import { NEXT_PUBLIC_THIRDWEB_AI_HOST } from "@/constants/public-envs"; +import { fetchWithAuthToken } from "./fetchWithAuthToken"; +import type { + DeletedSessionInfo, + NebulaContext, + SessionInfo, + TruncatedSessionInfo, + UpdatedSessionInfo, +} from "./types"; + +export async function createSession(params: { + project: Project; + context: NebulaContext | undefined; +}) { + const body: Record = {}; + + if (params.context) { + body.context = { + chain_ids: params.context.chainIds || [], + networks: params.context.networks, + wallet_address: params.context.walletAddress, + }; + } + + const res = await fetchWithAuthToken({ + project: params.project, + body: body, + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session`, + method: "POST", + }); + + if (!res.ok) { + const error = await res.text(); + throw new Error(`Failed to create session: ${error}`); + } + const data = await res.json(); + + return data.result as SessionInfo; +} + +export async function updateSession(params: { + project: Project; + sessionId: string; + contextFilters: NebulaContext | undefined; +}) { + const body: Record = {}; + + if (params.contextFilters) { + body.context = { + chain_ids: params.contextFilters.chainIds || [], + networks: params.contextFilters.networks, + wallet_address: params.contextFilters.walletAddress, + }; + } + + const res = await fetchWithAuthToken({ + body: body, + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`, + method: "PUT", + project: params.project, + }); + + if (!res.ok) { + throw new Error("Failed to update session"); + } + const data = await res.json(); + + return data.result as UpdatedSessionInfo; +} + +export async function deleteSession(params: { + project: Project; + sessionId: string; +}) { + const res = await fetchWithAuthToken({ + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`, + method: "DELETE", + project: params.project, + }); + + if (!res.ok) { + throw new Error("Failed to update session"); + } + const data = await res.json(); + + return data.result as DeletedSessionInfo; +} + +export async function getSessions(params: { project: Project }) { + const res = await fetchWithAuthToken({ + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/list`, + method: "GET", + project: params.project, + }); + + if (!res.ok) { + throw new Error("Failed to update session"); + } + const data = await res.json(); + + return data.result as TruncatedSessionInfo[]; +} + +export async function getSessionById(params: { + project: Project; + sessionId: string; +}) { + const res = await fetchWithAuthToken({ + endpoint: `${NEXT_PUBLIC_THIRDWEB_AI_HOST}/session/${params.sessionId}`, + method: "GET", + project: params.project, + }); + + if (!res.ok) { + throw new Error("Failed to update session"); + } + const data = await res.json(); + + return data.result as SessionInfo; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts new file mode 100644 index 00000000000..25dbf7a4b63 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/api/types.ts @@ -0,0 +1,121 @@ +type SessionContextFilter = { + chain_ids: string[] | null; + wallet_address: string | null; +}; + +type NebulaUserMessageContentItem = + | { + type: "image"; + image_url: string | null; + b64: string | null; + } + | { + 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" | "image"; + content: string; + timestamp: number; + } + | { + role: "user"; + content: NebulaUserMessageContent | string; + }; + +export type SessionInfo = { + id: string; + account_id: string; + model_name: string; + archive_at: string | null; + can_execute: boolean; + created_at: string; + deleted_at: string | null; + history: Array | null; + updated_at: string; + archived_at: string | null; + title: string | null; + is_public: boolean | null; + context: SessionContextFilter | null; + // memory + // action: array | null; <-- type of this is not available on https://nebula-api.thirdweb-dev.com/docs#/default/get_session_session__session_id__get +}; + +export type UpdatedSessionInfo = { + title: string; + model_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; +}; + +export type NebulaTxData = { + chain_id: number; + data: `0x${string}`; + to: string; + value?: string; +}; + +export type NebulaContext = { + chainIds: string[] | null; + walletAddress: string | null; + networks: "mainnet" | "testnet" | "all" | null; +}; + +export type NebulaSwapData = { + action: string; + transaction: { + chain_id: number; + to: `0x${string}`; + data: `0x${string}`; + value?: string; + }; + to_token: { + address: `0x${string}`; + amount: string; + chain_id: number; + decimals: number; + symbol: string; + }; + from_token: { + address: `0x${string}`; + amount: string; + chain_id: number; + decimals: number; + symbol: string; + }; + intent: { + amount: string; + destination_chain_id: number; + destination_token_address: `0x${string}`; + origin_chain_id: number; + origin_token_address: `0x${string}`; + receiver: `0x${string}`; + sender: `0x${string}`; + }; +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/[session_id]/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/[session_id]/page.tsx new file mode 100644 index 00000000000..d26d52c224e --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/[session_id]/page.tsx @@ -0,0 +1,65 @@ +import { notFound } from "next/navigation"; +import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token"; +import { getProject } from "@/api/project/projects"; +import { getSessionById, getSessions } from "../../api/session"; +import { ChatPageContent } from "../../components/ChatPageContent"; +import { ChatPageLayout } from "../../components/ChatPageLayout"; + +export default async function Page(props: { + params: Promise<{ + team_slug: string; + project_slug: string; + session_id: string; + }>; +}) { + const [params] = await Promise.all([props.params]); + const [authToken, project] = await Promise.all([ + getAuthToken(), + getProject(params.team_slug, params.project_slug), + ]); + + if (!authToken) { + notFound(); + } + + if (!project) { + notFound(); + } + + const client = await getUserThirdwebClient({ teamId: project.teamId }); + + const [session, sessions] = await Promise.all([ + getSessionById({ + project: project, + sessionId: params.session_id, + }).catch(() => undefined), + getSessions({ + project: project, + }).catch(() => []), + ]); + + if (!session) { + notFound(); + } + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx new file mode 100644 index 00000000000..8714dc18fc8 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { subDays } from "date-fns"; +import { ThirdwebProvider } from "thirdweb/react"; +import { projectStub, randomLorem } from "@/storybook/stubs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { ChatPageLayout } from "../../components/ChatPageLayout"; +import { ChatHistoryPageUI } from "./ChatHistoryPage"; + +const meta = { + component: Variant, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/history", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TenChats: Story = { + args: { + length: 10, + }, +}; + +export const ZeroChats: Story = { + args: { + length: 0, + }, +}; + +export const OneChat: Story = { + args: { + length: 1, + }, +}; + +export const PrefillSearch: Story = { + args: { + length: 10, + prefillSearch: "xxxxxxxxxxxx", + }, +}; + +function createRandomSessions(length: number) { + const sessions = []; + for (let i = 0; i < length; i++) { + sessions.push({ + created_at: new Date().toISOString(), + id: Math.random().toString(), + title: randomLorem(Math.floor(5 + Math.random() * 15)), + updated_at: subDays( + new Date(), + Math.floor(Math.random() * 10), + ).toISOString(), + }); + } + + return sessions; +} + +function Variant(props: { length: number; prefillSearch?: string }) { + return ( + + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} + prefillSearch={props.prefillSearch} + sessions={createRandomSessions(props.length)} + team_slug="team-1" + project={projectStub("xxxxx", "team-1")} + /> + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx new file mode 100644 index 00000000000..445bab8cfbf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/ChatHistoryPage.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { ScrollShadow } from "@workspace/ui/components/scroll-shadow"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { formatDistance } from "date-fns"; +import Fuse from "fuse.js"; +import { + EllipsisIcon, + MessageCircleIcon, + SearchIcon, + TrashIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { deleteSession } from "../../api/session"; +import type { TruncatedSessionInfo } from "../../api/types"; +import { useSessionsWithLocalOverrides } from "../../hooks/useSessionsWithLocalOverrides"; +import { deletedSessionsStore } from "../../stores"; + +export function ChatHistoryPage(props: { + sessions: TruncatedSessionInfo[]; + prefillSearch?: string; + team_slug: string; + project: Project; +}) { + return ( + { + await deleteSession({ + project: props.project, + sessionId: s, + }); + }} + /> + ); +} + +export function ChatHistoryPageUI(props: { + sessions: TruncatedSessionInfo[]; + prefillSearch?: string; + deleteSession: (sessionId: string) => Promise; + team_slug: string; + project: Project; +}) { + const [searchVal, setSearchVal] = useState(props.prefillSearch ?? ""); + const allSessions = useSessionsWithLocalOverrides(props.sessions); + + const fuse = useMemo(() => { + return new Fuse(allSessions, { + keys: [ + { + name: "title", + weight: 1, + }, + ], + threshold: 0.5, + }); + }, [allSessions]); + + const filteredSessions = useMemo(() => { + if (!searchVal) { + return allSessions; + } + + return fuse.search(searchVal).map((e) => e.item); + }, [allSessions, searchVal, fuse]); + return ( +
+
+

All Chats

+
+ + + {filteredSessions.length > 0 && ( + + {filteredSessions.length > 0 && ( +
+ {filteredSessions.map((session) => ( + + ))} +
+ )} +
+ )} + + {/* No Search Results */} + {allSessions.length > 0 && filteredSessions.length === 0 && ( +
+
+
+ +
+
+

No Chats Found

+
+

+ Try a different search term +

+
+
+ )} + + {/* No Chats at all */} + {allSessions.length === 0 && ( +
+
+
+ +
+
+

No Chats Created

+
+

+ Start a new chat to get started. +

+
+ +
+
+ )} +
+ ); +} + +function SearchInput(props: { + placeholder: string; + value: string; + onValueChange: (value: string) => void; +}) { + return ( +
+ props.onValueChange(e.target.value)} + placeholder={props.placeholder} + value={props.value} + /> + +
+ ); +} + +// TODO - add delete chat confirmation dialog + +function SessionCard(props: { + session: TruncatedSessionInfo; + project: Project; + team_slug: string; + deleteSession: (sessionId: string) => Promise; +}) { + const deleteChat = useMutation({ + mutationFn: () => { + return props.deleteSession(props.session.id); + }, + onSuccess: () => { + const prev = deletedSessionsStore.getValue(); + deletedSessionsStore.setValue([...prev, props.session.id]); + }, + }); + + return ( +
+

+ + {props.session.title || "Untitled"} + +

+
+

+ Updated{" "} + {formatDistance(new Date(props.session.updated_at), new Date(), { + addSuffix: true, + })} +

+ + + + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/page.tsx new file mode 100644 index 00000000000..582e214ab02 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/chat/history/page.tsx @@ -0,0 +1,25 @@ +import { notFound } from "next/navigation"; +import { getProject } from "@/api/project/projects"; +import { getSessions } from "../../api/session"; +import { ChatHistoryPage } from "./ChatHistoryPage"; + +export default async function Page(props: { + params: Promise<{ team_slug: string; project_slug: string }>; +}) { + const [params] = await Promise.all([props.params]); + + const project = await getProject(params.team_slug, params.project_slug); + + if (!project) { + notFound(); + } + + const sessions = await getSessions({ project }).catch(() => []); + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx new file mode 100644 index 00000000000..8d703e588cb --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.stories.tsx @@ -0,0 +1,101 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { type AssetBalance, AssetsSectionUI } from "./AssetsSection"; + +const meta = { + component: AssetsSectionUI, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + title: "AI/AssetsSection", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const tokensStub: AssetBalance[] = [ + { + balance: "10000000000000000000000", + chain_id: 8453, + decimals: 18, + name: "Broge", + symbol: "BROGE", + token_address: "0xe8e55a847bb446d967ef92f4580162fb8f2d3f38", + }, + { + balance: "2", + chain_id: 8453, + decimals: 18, + name: "clBTC", + symbol: "clBTC", + token_address: "0x8d2757ea27aabf172da4cca4e5474c76016e3dc5", + }, + { + balance: "1000000000000000000", + chain_id: 8453, + decimals: 18, + name: "BASED USA", + symbol: "USA", + token_address: "0xb56d0839998fd79efcd15c27cf966250aa58d6d3", + }, + { + balance: "48888800000000000000", + chain_id: 8453, + decimals: 18, + name: "BearPaw", + symbol: "PAW", + token_address: "0x600c9b69a65fb6d2551623a53ddef17b050233cd", + }, + { + balance: "69000000000000000000", + chain_id: 8453, + decimals: 18, + name: "Degen Point Of View", + symbol: "POV", + token_address: "0x4c96a67b0577358894407af7bc3158fc1dffbeb5", + }, + { + balance: "6237535850425", + chain_id: 8453, + decimals: 18, + name: "Wrapped Ether", + symbol: "WETH", + token_address: "0x4200000000000000000000000000000000000006", + }, +]; + +export const MultipleAssets: Story = { + args: { + client: storybookThirdwebClient, + data: tokensStub, + isPending: false, + }, +}; + +export const SingleAsset: Story = { + args: { + client: storybookThirdwebClient, + data: tokensStub.slice(0, 1), + isPending: false, + }, +}; + +export const EmptyAssets: Story = { + args: { + client: storybookThirdwebClient, + data: [], + isPending: false, + }, +}; + +export const Loading: Story = { + args: { + client: storybookThirdwebClient, + data: [], + isPending: true, + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx new file mode 100644 index 00000000000..23e44d170a6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/AssetsSection/AssetsSection.tsx @@ -0,0 +1,254 @@ +"use client"; +import { useQuery } from "@tanstack/react-query"; +import { XIcon } from "lucide-react"; +import Link from "next/link"; +import { + defineChain, + getAddress, + NATIVE_TOKEN_ADDRESS, + type ThirdwebClient, + toTokens, +} from "thirdweb"; +import { + Blobbie, + TokenIcon, + TokenProvider, + useActiveAccount, + useActiveWalletChain, +} from "thirdweb/react"; +import { getWalletBalance } from "thirdweb/wallets"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isProd } from "@/constants/env-utils"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { ChainIconClient } from "@/icons/ChainIcon"; + +export type AssetBalance = { + chain_id: number; + token_address: string; + balance: string; + name: string; + symbol: string; + decimals: number; +}; + +export function AssetsSectionUI(props: { + data: AssetBalance[]; + isPending: boolean; + client: ThirdwebClient; +}) { + if (props.data.length === 0 && !props.isPending) { + return ( +
+
+ +
+
No Assets
+
+ ); + } + + return ( +
+ {!props.isPending && + props.data.map((asset) => ( + + ))} + + {props.isPending && + new Array(10).fill(null).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: for the placeholder this is explicitly the key + + ))} +
+ ); +} + +function SkeletonAssetItem() { + return ( +
+ +
+ + +
+
+ ); +} + +function AssetItem(props: { asset: AssetBalance; client: ThirdwebClient }) { + const { idToChain } = useAllChainsData(); + const chainMeta = idToChain.get(props.asset.chain_id); + const isNativeToken = props.asset.token_address === NATIVE_TOKEN_ADDRESS; + const chain = useV5DashboardChain(props.asset.chain_id); + + return ( + +
+
+ + } + loadingComponent={ + + } + /> + {!isNativeToken && ( +
+ +
+ )} +
+ +
+ + {props.asset.name} + + +

+ {`${toTokens(BigInt(props.asset.balance), props.asset.decimals)} ${props.asset.symbol}`} +

+
+
+
+ ); +} + +export function AssetsSection(props: { client: ThirdwebClient }) { + const account = useActiveAccount(); + const activeChain = useActiveWalletChain(); + + const assetsQuery = useQuery({ + enabled: !!account && !!activeChain, + queryFn: async () => { + if (!account || !activeChain) { + return []; + } + const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; + const url = new URL( + `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/erc20/${account?.address}`, + ); + url.searchParams.set("limit", "50"); + url.searchParams.set("metadata", "true"); + url.searchParams.set("include_spam", "false"); + url.searchParams.set("clientId", props.client.clientId); + for (const chain of chains) { + url.searchParams.append("chain", chain.toString()); + } + + const response = await fetch(url.toString()); + const json = (await response.json()) as { + data: AssetBalance[]; + }; + + const tokensToShowOnTop = new Set( + [ + // base + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // usdc, + "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", // wbtc + "0x4200000000000000000000000000000000000006", // wrapped eth + // ethereum + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // usdc + "0xdac17f958d2ee523a2206206994597c13d831ec7", // usdt + "0xB8c77482e45F1F44dE1745F52C74426C631bDD52", // bnb + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // weth + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", // wbtc + // optimism + "0x4200000000000000000000000000000000000042", // op token + "0xdc6ff44d5d932cbd77b52e5612ba0529dc6226f1", // world coin + "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", // usdt + "0x0b2c639c533813f4aa9d7837caf62653d097ff85", // usdc + "0x4200000000000000000000000000000000000006", // wrapped eth + // polygon + "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619", // weth + "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", // usdt + "0x3BA4c387f786bFEE076A58914F5Bd38d668B42c3", // bnb + "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // usdc + "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", // usdc.e + ].map((x) => getAddress(x)), + ); + + return json.data.sort((a, b) => { + if (tokensToShowOnTop.has(getAddress(a.token_address))) { + return -1; + } + if (tokensToShowOnTop.has(getAddress(b.token_address))) { + return 1; + } + return 0; + }); + }, + queryKey: ["v1/tokens/erc20", account?.address, activeChain?.id], + }); + + const nativeBalances = useQuery({ + queryFn: async () => { + if (!account || !activeChain) { + return []; + } + + const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; + + const result = await Promise.allSettled( + chains.map((chain) => + getWalletBalance({ + address: account.address, + // eslint-disable-next-line no-restricted-syntax + chain: defineChain(chain), + client: props.client, + }), + ), + ); + + return result + .filter((r) => r.status === "fulfilled") + .map((r) => ({ + balance: r.value.value.toString(), + chain_id: r.value.chainId, + decimals: r.value.decimals, + name: r.value.name, + symbol: r.value.symbol, + token_address: r.value.tokenAddress, + })) + .filter((x) => x.balance !== "0") as AssetBalance[]; + }, + queryKey: ["getWalletBalance", account?.address, activeChain?.id], + }); + + const isPending = assetsQuery.isPending || nativeBalances.isPending; + + const data = [...(nativeBalances.data ?? []), ...(assetsQuery.data ?? [])]; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx new file mode 100644 index 00000000000..b4e5fe54604 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatBar.tsx @@ -0,0 +1,720 @@ +/** biome-ignore-all lint/a11y/useSemanticElements: TODO */ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { + ArrowUpIcon, + CheckIcon, + ChevronDownIcon, + CircleStopIcon, + CopyIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { ThirdwebClient } from "thirdweb"; +import { + AccountAvatar, + AccountBlobbie, + AccountName, + AccountProvider, + useActiveWallet, + WalletIcon, + WalletName, + WalletProvider, +} from "thirdweb/react"; +import { shortenAddress } from "thirdweb/utils"; +import type { Wallet } from "thirdweb/wallets"; +import { Img } from "@/components/blocks/Img"; +import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { ImageUploadButton } from "@/components/ui/image-upload-button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AutoResizeTextarea } from "@/components/ui/textarea"; +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import { cn } from "@/lib/utils"; +import type { NebulaContext, NebulaUserMessage } from "../api/types"; + +export type WalletMeta = { + walletId: Wallet["id"]; + address: string; +}; + +const maxAllowedImagesPerMessage = 4; + +function showSigninToUploadImagesToast() { + toast.error("Sign in to upload images to Nebula", { + position: "top-right", + }); +} + +export function ChatBar(props: { + sendMessage: (message: NebulaUserMessage) => void; + isChatStreaming: boolean; + abortChatStream: () => void; + prefillMessage: string | undefined; + className?: string; + context: NebulaContext | undefined; + setContext: (context: NebulaContext | undefined) => void; + showContextSelector: boolean; + client: ThirdwebClient; + connectedWallets: WalletMeta[]; + setActiveWallet: (wallet: WalletMeta) => void; + isConnectingWallet: boolean; + allowImageUpload: boolean; + onLoginClick: undefined | (() => void); + placeholder: string; +}) { + const [message, setMessage] = useState(props.prefillMessage || ""); + const selectedChainIds = props.context?.chainIds?.map((x) => Number(x)) || []; + const firstChainId = selectedChainIds[0]; + const [images, setImages] = useState< + Array<{ file: File; b64: string | undefined }> + >([]); + const [isDragOver, setIsDragOver] = useState(false); + + function handleSubmit(message: string) { + const userMessage: NebulaUserMessage = { + content: [{ text: message, type: "text" }], + role: "user", + }; + if (images.length > 0) { + for (const image of images) { + if (image.b64) { + userMessage.content.push({ + b64: image.b64, + image_url: null, + type: "image", + }); + } + } + } + props.sendMessage(userMessage); + setMessage(""); + setImages([]); + } + + const uploadImageMutation = useMutation({ + mutationFn: async (image: File) => { + return toBase64(image); + }, + }); + + const supportedFileTypes = ["image/jpeg", "image/png", "image/webp"]; + + async function handleImageUpload(files: File[]) { + const totalFiles = files.length + images.length; + + if (totalFiles > maxAllowedImagesPerMessage) { + toast.error( + `You can only upload up to ${maxAllowedImagesPerMessage} images at a time`, + { + position: "top-right", + }, + ); + return; + } + + const validFiles: File[] = []; + + for (const file of files) { + if (!supportedFileTypes.includes(file.type)) { + toast.error("Unsupported file type", { + description: `File: ${file.name}`, + position: "top-right", + }); + continue; + } + + if (file.size <= 5 * 1024 * 1024) { + validFiles.push(file); + } else { + toast.error("Image is larger than 5MB", { + description: `File: ${file.name}`, + position: "top-right", + }); + } + } + + try { + const urls = await Promise.all( + validFiles.map(async (image) => { + const b64 = await uploadImageMutation.mutateAsync(image); + return { b64: b64, file: image }; + }), + ); + + setImages((prev) => [...prev, ...urls]); + } catch (e) { + console.error(e); + toast.error("Failed to upload image", { + position: "top-right", + }); + } + } + + return ( + + {/** biome-ignore lint/a11y/noStaticElementInteractions: TODO */} +
{ + e.preventDefault(); + setIsDragOver(true); + if (!props.allowImageUpload) { + return; + } + }} + onDragLeave={(e) => { + e.preventDefault(); + if (!props.allowImageUpload) { + return; + } + // Only set drag over to false if we're leaving the container entirely + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragOver(false); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setIsDragOver(true); + if (!props.allowImageUpload) { + return; + } + }} + onDrop={(e) => { + setIsDragOver(false); + e.preventDefault(); + if (!props.allowImageUpload) { + showSigninToUploadImagesToast(); + return; + } + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) handleImageUpload(files); + }} + > + {images.length > 0 && ( + { + setImages((prev) => prev.filter((_, i) => i !== index)); + }} + /> + )} + +
+
+ setMessage(e.target.value)} + onKeyDown={(e) => { + // ignore if shift key is pressed to allow entering new lines + if (e.shiftKey) { + return; + } + if (e.key === "Enter" && !props.isChatStreaming) { + e.preventDefault(); + handleSubmit(message); + } + }} + onPaste={(e) => { + const files = Array.from(e.clipboardData.files); + if (files.length > 0) { + e.preventDefault(); + if (!props.allowImageUpload) { + showSigninToUploadImagesToast(); + return; + } + handleImageUpload(files); + } + }} + placeholder={props.placeholder} + value={message} + /> +
+ +
+ {/* left */} +
+ {props.showContextSelector && ( +
+ {props.connectedWallets.length > 1 && + !props.isConnectingWallet && ( + { + props.setActiveWallet(walletMeta); + props.setContext({ + chainIds: props.context?.chainIds || [], + networks: props.context?.networks || null, + walletAddress: walletMeta.address, + }); + }} + selectedAddress={ + props.context?.walletAddress || undefined + } + wallets={props.connectedWallets} + /> + )} + + {props.isConnectingWallet && ( + + + Connecting Wallet + + )} + + + {selectedChainIds.length > 0 && firstChainId && ( + + )} + + {selectedChainIds.length === 0 && ( + + Select Chains + + + )} + + } + disableChainId + hideTestnets + onChange={(values) => { + props.setContext({ + chainIds: values.map((x) => x.toString()), + networks: props.context?.networks || null, + walletAddress: props.context?.walletAddress || null, + }); + }} + popoverContentClassName="!w-[calc(100vw-80px)] lg:!w-[320px]" + priorityChains={[ + 1, // ethereum + 56, // bnb smart chain mainnet (bsc) + 42161, // arbitrum one mainnet + 8453, // base mainnet + 43114, // avalanche mainnet + 146, // sonic + 137, // polygon + 80094, // berachain mainnet + 10, // optimism + ]} + selectedChainIds={selectedChainIds} + showSelectedValuesInModal={true} + side="top" + /> +
+ )} +
+ + {/* right */} +
+ {props.allowImageUpload ? ( + { + handleImageUpload(files); + }} + value={undefined} + variant="ghost" + > + + + + + ) : props.onLoginClick ? ( + + + + + +
+

+ Get access to image uploads by signing in to Nebula +

+ +
+
+
+ ) : null} + + {/* Send / Stop */} + {props.isChatStreaming ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +async function toBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); // includes "data:;base64," prefix + reader.onload = () => + resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = (error) => reject(error); + }); +} + +function ImagePreview(props: { + images: Array<{ file: File; b64: string | undefined }>; + isUploading: boolean; + onRemove: (index: number) => void; +}) { + return ( +
+ {props.images.map((image, index) => { + return ( +
+ + +
+ } + src={image.b64} + /> +
+

+ {props.isUploading ? "Uploading..." : image.file.name} +

+

+ {Math.round(image.file.size / 1024)} kB +

+
+ + + + +
+ ); + })} +
+ ); +} + +function ChainBadge(props: { + chainId: number; + plusMore: number; + client: ThirdwebClient; +}) { + const { idToChain } = useAllChainsData(); + const chain = idToChain.get(props.chainId); + + return ( + + + {chain?.name ? shortenChainName(chain.name) : `Chain #${props.chainId}`} + {props.plusMore > 0 && ( + +{props.plusMore} + )} + + + ); +} + +function shortenChainName(chainName: string) { + return chainName.replace("Mainnet", "").trim(); +} + +function WalletSelector(props: { + wallets: WalletMeta[]; + onClick: (wallet: WalletMeta) => void; + client: ThirdwebClient; + selectedAddress: string | undefined; +}) { + const [open, setOpen] = useState(false); + const activeWallet = useActiveWallet(); + + const accountBlobbie = ; + const accountAvatarFallback = ( + + ); + + if (!props.selectedAddress || !activeWallet) { + return null; + } + + // show smart account first + const sortedWallets = props.wallets.sort((a, b) => { + if (a.walletId === "smart") return -1; + if (b.walletId === "smart") return 1; + return 0; + }); + + return ( + + + + + + +
+

+ Select account to use for executing transactions +

+
+ +
+ {sortedWallets.map((wallet) => { + const accountBlobbie = ( + + ); + const accountAvatarFallback = ( + + ); + + return ( +
{ + setOpen(false); + props.onClick(wallet); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setOpen(false); + props.onClick(wallet); + } + }} + role="button" + tabIndex={0} + > +
+ + + +
+ + +
+
+ + {shortenAddress(wallet.address)} + + } + loadingComponent={ + + {shortenAddress(wallet.address)} + + } + /> + + + + {wallet.walletId === "smart" && ( + + Gasless + + )} +
+ +
+ {wallet.walletId === "smart" ? ( + "Smart Account" + ) : ( + Your Account + } + loadingComponent={ + + } + /> + )} +
+
+
+
+
+
+
+ + {props.selectedAddress === wallet.address && ( + + )} +
+ ); + })} +
+
+
+ ); +} + +function CopyButton(props: { address: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx new file mode 100644 index 00000000000..e250d777e1f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageContent.tsx @@ -0,0 +1,754 @@ +"use client"; +import { ArrowRightIcon, MessageSquareXIcon } from "lucide-react"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { + useActiveAccount, + useActiveWalletConnectionStatus, + useConnectedWallets, + useSetActiveWallet, +} from "thirdweb/react"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { promptNebula } from "../api/chat"; +import { createSession, updateSession } from "../api/session"; +import type { + NebulaContext, + NebulaSessionHistoryMessage, + NebulaUserMessage, + SessionInfo, +} from "../api/types"; +import { examplePrompts } from "../data/examplePrompts"; +import { newSessionsStore } from "../stores"; +import { ChatBar, type WalletMeta } from "./ChatBar"; +import { type ChatMessage, Chats } from "./Chats"; +import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent"; + +export function ChatPageContent(props: { + project: Project; + session: SessionInfo | undefined; + accountAddress: string; + authToken: string; + client: ThirdwebClient; + type: "landing" | "new-chat"; + initialParams: + | { + q: string | undefined; + chainIds: number[]; + } + | undefined; +}) { + const address = useActiveAccount()?.address; + const connectionStatus = useActiveWalletConnectionStatus(); + const connectedWallets = useConnectedWallets(); + const setActiveWallet = useSetActiveWallet(); + + const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); + const [messages, setMessages] = useState>(() => { + if (props.session?.history) { + return parseHistoryToMessages(props.session.history); + } + return []; + }); + + const [_contextFilters, _setContextFilters] = useState< + NebulaContext | undefined + >(() => { + const contextRes = props.session?.context; + const value: NebulaContext = { + chainIds: + contextRes?.chain_ids || + props.initialParams?.chainIds.map((x) => x.toString()) || + [], + networks: "mainnet", + walletAddress: contextRes?.wallet_address || props.accountAddress || null, + }; + + return value; + }); + + const contextFilters = useMemo(() => { + return { + chainIds: _contextFilters?.chainIds || [], + networks: _contextFilters?.networks || null, + walletAddress: address || _contextFilters?.walletAddress || null, + } satisfies NebulaContext; + }, [_contextFilters, address]); + + const setContextFilters = useCallback((v: NebulaContext | undefined) => { + _setContextFilters(v); + saveLastUsedChainIds(v?.chainIds || undefined); + }, []); + + const shouldRunEffect = useRef(true); + // if this is a new session, + // update chains to the last used chains in context filter + // we have to do this in effect to avoid hydration errors + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + // if viewing a session or context is set via params - do not update context + if (props.session || props.initialParams?.q || !shouldRunEffect.current) { + return; + } + + shouldRunEffect.current = false; + + _setContextFilters((_contextFilters) => { + try { + const lastUsedChainIds = getLastUsedChainIds(); + if (lastUsedChainIds) { + return { + chainIds: lastUsedChainIds, + networks: _contextFilters?.networks || null, + walletAddress: _contextFilters?.walletAddress || null, + }; + } + } catch { + // ignore local storage errors + } + + return _contextFilters; + }); + }, [props.session, props.initialParams?.q]); + + const [sessionId, setSessionId] = useState( + props.session?.id, + ); + + const [chatAbortController, setChatAbortController] = useState< + AbortController | undefined + >(); + + const [isChatStreaming, setIsChatStreaming] = useState(false); + const [enableAutoScroll, setEnableAutoScroll] = useState(false); + const [showConnectModal, setShowConnectModal] = useState(false); + + const initSession = useCallback(async () => { + const session = await createSession({ + project: props.project, + context: contextFilters, + }); + setSessionId(session.id); + return session; + }, [contextFilters, props.project]); + + const handleSendMessage = useCallback( + async (message: NebulaUserMessage) => { + setUserHasSubmittedMessage(true); + setMessages((prev) => [ + ...prev, + { + content: message.content, + type: "user", + }, + { + texts: [], + type: "presence", + }, + ]); + + // handle hardcoded replies first + const lowerCaseMessage = message.content + .find((x) => x.type === "text") + ?.text.toLowerCase(); + + const interceptedReply = examplePrompts.find( + (prompt) => prompt.message.toLowerCase() === lowerCaseMessage, + )?.interceptedReply; + if (interceptedReply) { + // slight delay to match other response times + await new Promise((resolve) => setTimeout(resolve, 1000)); + setMessages((prev) => [ + ...prev.slice(0, -1), + { request_id: undefined, text: interceptedReply, type: "assistant" }, + ]); + + return; + } + + setIsChatStreaming(true); + setEnableAutoScroll(true); + const abortController = new AbortController(); + + try { + // Ensure we have a session ID + let currentSessionId = sessionId; + if (!currentSessionId) { + const session = await initSession(); + currentSessionId = session.id; + } + + const firstTextMessage = + message.role === "user" + ? message.content.find((x) => x.type === "text")?.text || "" + : ""; + + // add this session on sidebar + if (messages.length === 0 && firstTextMessage) { + const prevValue = newSessionsStore.getValue(); + newSessionsStore.setValue([ + { + created_at: new Date().toISOString(), + id: currentSessionId, + title: firstTextMessage, + updated_at: new Date().toISOString(), + }, + ...prevValue, + ]); + } + + setChatAbortController(abortController); + + await handleNebulaPrompt({ + abortController, + project: props.project, + authToken: props.authToken, + contextFilters: contextFilters, + message: message, + sessionId: currentSessionId, + setContextFilters, + setMessages, + }); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + + handleNebulaPromptError({ + error, + setMessages, + }); + } finally { + setIsChatStreaming(false); + setEnableAutoScroll(false); + } + }, + [ + sessionId, + props.project, + contextFilters, + props.authToken, + messages.length, + initSession, + setContextFilters, + ], + ); + + const hasDoneAutoPrompt = useRef(false); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if ( + props.initialParams?.q && + messages.length === 0 && + !hasDoneAutoPrompt.current + ) { + hasDoneAutoPrompt.current = true; + handleSendMessage({ + content: [ + { + text: props.initialParams.q, + type: "text", + }, + ], + role: "user", + }); + } + }, [props.initialParams?.q, messages.length, handleSendMessage]); + + const showEmptyState = + !userHasSubmittedMessage && + messages.length === 0 && + !props.session && + !props.initialParams?.q; + + const sessionWithNoMessages = props.session && messages.length === 0; + + const connectedWalletsMeta: WalletMeta[] = connectedWallets.map((x) => ({ + address: x.getAccount()?.address || "", + walletId: x.id, + })); + + const handleUpdateContextFilters = async ( + values: NebulaContext | undefined, + ) => { + // if session is not yet created, don't need to update sessions - starting a chat will create a session with the context + if (sessionId) { + await updateSession({ + project: props.project, + contextFilters: values, + sessionId, + }); + } + }; + + const handleSetActiveWallet = (walletMeta: WalletMeta) => { + const wallet = connectedWallets.find( + (x) => x.getAccount()?.address === walletMeta.address, + ); + if (wallet) { + setActiveWallet(wallet); + } + }; + + return ( +
+ + +
+
+ {showEmptyState ? ( +
+ +
+ ) : ( +
+ {sessionWithNoMessages && ( +
+
+
+ +
+

+ No messages found +

+

+ This session was aborted before receiving any messages +

+
+
+ )} + + {messages.length > 0 && ( + + )} + +
+ { + chatAbortController?.abort(); + setChatAbortController(undefined); + setIsChatStreaming(false); + }} + allowImageUpload={true} + client={props.client} + connectedWallets={connectedWalletsMeta} + context={contextFilters} + isChatStreaming={isChatStreaming} + isConnectingWallet={connectionStatus === "connecting"} + onLoginClick={undefined} + placeholder="Ask thirdweb AI" + prefillMessage={undefined} + sendMessage={handleSendMessage} + setActiveWallet={handleSetActiveWallet} + setContext={(v) => { + setContextFilters(v); + handleUpdateContextFilters(v); + }} + showContextSelector={true} + /> +
+
+ )} + +

+ thirdweb AI may make mistakes. Please use with discretion +

+
+
+
+ ); +} + +function WalletDisconnectedDialog(props: { + open: boolean; + onOpenChange: (value: boolean) => void; +}) { + return ( + + +
+ + Wallet Disconnected + + Connect your wallet to continue + + +
+ +
+ + + + +
+
+
+ ); +} + +const AI_LAST_USED_CHAIN_IDS_KEY = "ai-last-used-chain-ids"; + +function saveLastUsedChainIds(chainIds: string[] | undefined) { + try { + if (chainIds && chainIds.length > 0) { + localStorage.setItem( + AI_LAST_USED_CHAIN_IDS_KEY, + JSON.stringify(chainIds), + ); + } else { + localStorage.removeItem(AI_LAST_USED_CHAIN_IDS_KEY); + } + } catch { + // ignore local storage errors + } +} + +function getLastUsedChainIds(): string[] | null { + try { + const lastUsedChainIdsStr = localStorage.getItem( + AI_LAST_USED_CHAIN_IDS_KEY, + ); + return lastUsedChainIdsStr ? JSON.parse(lastUsedChainIdsStr) : null; + } catch { + return null; + } +} + +async function handleNebulaPrompt(params: { + project: Project; + abortController: AbortController; + message: NebulaUserMessage; + sessionId: string; + authToken: string; + setMessages: React.Dispatch>; + contextFilters: NebulaContext | undefined; + setContextFilters: (v: NebulaContext | undefined) => void; +}) { + const { + project, + abortController, + message, + sessionId, + authToken, + setMessages, + contextFilters, + setContextFilters, + } = params; + let requestIdForMessage = ""; + + let hasReceivedResponse = false; + + await promptNebula({ + project, + abortController, + authToken, + context: contextFilters, + handleStream(res) { + if (abortController.signal.aborted) { + return; + } + + switch (res.event) { + case "init": { + requestIdForMessage = res.data.request_id; + return; + } + + case "image": { + hasReceivedResponse = true; + setMessages((prevMessages) => { + return [ + ...prevMessages, + { + data: res.data, + request_id: res.request_id, + type: "image", + }, + ]; + }); + return; + } + + case "delta": { + // ignore empty string delta + if (!res.data.v) { + return; + } + + hasReceivedResponse = true; + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + + // append to previous assistant message + if (lastMessage?.type === "assistant") { + return [ + ...prev.slice(0, -1), + { + request_id: requestIdForMessage, + text: lastMessage.text + res.data.v, + type: "assistant", + }, + ]; + } + + // start a new assistant message + return [ + ...prev, + { + request_id: requestIdForMessage, + text: res.data.v, + type: "assistant", + }, + ]; + }); + return; + } + + case "presence": { + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + + // append to previous presence message + if (lastMessage?.type === "presence") { + return [ + ...prev.slice(0, -1), + { + texts: [...lastMessage.texts, res.data.data], + type: "presence", + }, + ]; + } + + // start a new presence message + return [...prev, { texts: [res.data.data], type: "presence" }]; + }); + return; + } + + case "action": { + hasReceivedResponse = true; + switch (res.type) { + case "sign_transaction": { + setMessages((prevMessages) => { + return [ + ...prevMessages, + { + data: res.data, + request_id: res.request_id, + subtype: res.type, + type: "action", + }, + ]; + }); + return; + } + case "sign_swap": { + setMessages((prevMessages) => { + return [ + ...prevMessages, + { + data: res.data, + request_id: res.request_id, + subtype: res.type, + type: "action", + }, + ]; + }); + return; + } + } + return; + } + + case "context": { + setContextFilters({ + chainIds: res.data.chain_ids.map((x) => x.toString()), + networks: res.data.networks, + walletAddress: res.data.wallet_address, + }); + return; + } + + case "error": { + hasReceivedResponse = true; + setMessages((prev) => { + return [ + ...prev, + { + text: res.data.errorMessage, + type: "error", + }, + ]; + }); + return; + } + } + }, + message, + sessionId, + }); + + // if the stream ends without any delta or tx events - we have nothing to show + // show an error message in that case + if (!hasReceivedResponse) { + setMessages((prev) => { + const newMessages = [...prev]; + + newMessages.push({ + text: "No response received, please try again", + type: "error", + }); + + return newMessages; + }); + } +} + +function handleNebulaPromptError(params: { + error: unknown; + setMessages: React.Dispatch>; +}) { + const { error, setMessages } = params; + console.error(error); + + setMessages((prev) => { + const newMessages = prev.slice( + 0, + prev[prev.length - 1]?.type === "presence" ? -1 : undefined, + ); + + // add error message + newMessages.push({ + text: `Error: ${error instanceof Error ? error.message : "Failed to execute command"}`, + type: "error", + }); + + return newMessages; + }); +} + +function parseHistoryToMessages(history: NebulaSessionHistoryMessage[]) { + const messages: ChatMessage[] = []; + + for (const message of history) { + switch (message.role) { + case "action": { + try { + const content = JSON.parse(message.content) as { + session_id: string; + data: string; + type: "sign_transaction" | "sign_swap"; + request_id: string; + }; + + if (content.type === "sign_transaction") { + const txData = JSON.parse(content.data); + messages.push({ + data: txData, + request_id: content.request_id, + subtype: "sign_transaction", + type: "action", + }); + } else if (content.type === "sign_swap") { + const swapData = JSON.parse(content.data); + messages.push({ + data: swapData, + request_id: content.request_id, + subtype: "sign_swap", + type: "action", + }); + } + } catch (e) { + console.error("error processing message", e, { message }); + } + break; + } + + case "image": { + const content = JSON.parse(message.content) as { + type: "image"; + request_id: string; + data: { + width: number; + height: number; + url: string; + }; + }; + + messages.push({ + data: content.data, + request_id: content.request_id, + type: "image", + }); + break; + } + + case "user": { + messages.push({ + content: + typeof message.content === "string" + ? [ + { + text: message.content, + type: "text", + }, + ] + : message.content, + type: message.role, + }); + break; + } + + case "assistant": { + messages.push({ + request_id: undefined, + text: message.content, + type: message.role, + }); + } + } + } + + return messages; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx new file mode 100644 index 00000000000..003462c58c7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ThirdwebProvider } from "thirdweb/react"; +import { projectStub, randomLorem } from "@/storybook/stubs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import type { TruncatedSessionInfo } from "../api/types"; +import { ChatPageLayout } from "./ChatPageLayout"; + +const meta = { + component: Variant, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/ChatPageLayout", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoRecentChats: Story = { + args: { + sessions: [], + }, +}; + +export const ThreeChats: Story = { + args: { + sessions: generateRandomSessions(3), + }, +}; + +export const ThirtyChats: Story = { + args: { + sessions: generateRandomSessions(30), + }, +}; + +function generateRandomSessions(count: number): TruncatedSessionInfo[] { + return Array.from({ length: count }, (_, i) => ({ + created_at: new Date().toISOString(), + id: i.toString(), + title: randomLorem(Math.floor(Math.random() * 10) + 1), + updated_at: new Date().toISOString(), + })); +} + +function Variant(props: { sessions: TruncatedSessionInfo[] }) { + return ( + +
+ CHILDREN +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx new file mode 100644 index 00000000000..82608cb3727 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatPageLayout.tsx @@ -0,0 +1,46 @@ +import type { ThirdwebClient } from "thirdweb"; +import type { Project } from "@/api/project/projects"; +import { cn } from "@/lib/utils"; +import type { TruncatedSessionInfo } from "../api/types"; +import { ChatSidebar } from "./ChatSidebar"; +import { MobileNav } from "./NebulaMobileNav"; + +export function ChatPageLayout(props: { + team_slug: string; + authToken: string; + project: Project; + client: ThirdwebClient; + accountAddress: string; + sessions: TruncatedSessionInfo[]; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ + + {props.children} + + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx new file mode 100644 index 00000000000..aa6fc5e729c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebar.tsx @@ -0,0 +1,257 @@ +"use client"; +import { + ChartLineIcon, + ChevronDownIcon, + ChevronRightIcon, + FileCode2Icon, + PlusIcon, +} from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { + AccountAvatar, + AccountBlobbie, + AccountProvider, + useActiveWallet, + WalletIcon, + WalletName, + WalletProvider, +} from "thirdweb/react"; +import { shortenAddress } from "thirdweb/utils"; +import type { Project } from "@/api/project/projects"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { TabButtons } from "@/components/ui/tabs"; +import { NebulaIcon } from "@/icons/NebulaIcon"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; +import type { TruncatedSessionInfo } from "../api/types"; +import { useSessionsWithLocalOverrides } from "../hooks/useSessionsWithLocalOverrides"; +import { AssetsSection } from "./AssetsSection/AssetsSection"; +import { ChatSidebarLink } from "./ChatSidebarLink"; +import { NebulaConnectWallet } from "./NebulaConnectButton"; +import { TransactionsSection } from "./TransactionsSection/TransactionsSection"; + +export function ChatSidebar(props: { + sessions: TruncatedSessionInfo[]; + team_slug: string; + project: Project; + client: ThirdwebClient; + type: "desktop" | "mobile"; +}) { + const sessions = useSessionsWithLocalOverrides(props.sessions); + const sessionsToShow = sessions.slice(0, 10); + const newChatPage = `/team/${props.team_slug}/${props.project.slug}/ai`; + const router = useDashboardRouter(); + const pathname = usePathname(); + + return ( +
+
+
+ + thirdweb AI +
+ + + Beta + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + Analytics +
+ + + +
+ + Documentation +
+ + +
+ + {sessionsToShow.length > 0 && ( +
+
+

Recent Chats

+ {sessionsToShow.length < sessions.length && ( + + View All + + + )} +
+ +
+ {sessionsToShow.map((session) => { + return ( + + ); + })} +
+
+ )} + +
+ +
+
+ ); +} + +function WalletDetails(props: { client: ThirdwebClient }) { + const [tab, setTab] = useState<"assets" | "transactions">("assets"); + const [isExpanded, setIsExpanded] = useState(true); + return ( + +
+ + +
+ + {isExpanded && ( +
+ setTab("assets"), + }, + { + isActive: tab === "transactions", + name: "Recent Activity", + onClick: () => setTab("transactions"), + }, + ]} + /> + + {isExpanded && ( +
+ {tab === "assets" && } + + {tab === "transactions" && ( + + )} +
+ )} +
+ )} +
+ ); +} + +function CustomConnectButton(props: { client: ThirdwebClient }) { + const activeWallet = useActiveWallet(); + const accountBlobbie = ; + const accountAvatarFallback = ( + + ); + + return ( +
+ { + return ( + + + + + + ); + } + : undefined + } + detailsButtonClassName="!bg-background hover:!border-active-border" + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx new file mode 100644 index 00000000000..5dda5f30ad9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ChatSidebarLink.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { EllipsisIcon, TrashIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { toast } from "sonner"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { NavLink } from "@/components/ui/NavLink"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { deleteSession } from "../api/session"; +import { deletedSessionsStore } from "../stores"; + +// TODO - add delete chat confirmation dialog + +export function ChatSidebarLink(props: { + sessionId: string; + title: string; + project: Project; + team_slug: string; +}) { + const router = useDashboardRouter(); + const pathname = usePathname(); + const isDeletingCurrentPage = pathname.includes(props.sessionId); + const newChatLink = `/team/${props.team_slug}/${props.project.slug}/ai`; + const deleteChat = useMutation({ + mutationFn: () => { + return deleteSession({ + project: props.project, + sessionId: props.sessionId, + }); + }, + onSuccess: () => { + const prev = deletedSessionsStore.getValue(); + deletedSessionsStore.setValue([...prev, props.sessionId]); + if (isDeletingCurrentPage) { + router.replace(newChatLink); + } + }, + }); + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chatbar.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chatbar.stories.tsx new file mode 100644 index 00000000000..dc8912d0562 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chatbar.stories.tsx @@ -0,0 +1,195 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { ThirdwebProvider } from "thirdweb/react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import type { NebulaContext } from "../api/types"; +import { ChatBar, type WalletMeta } from "./ChatBar"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/Chatbar", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +const smartWalletAddress = "0x61230342D8D377cA437BdC7CD02C09e09A470500"; +const userWalletAddress = "0x2d7B4e58bb163462cba2e705090a4EC56A958F2a"; + +function Story() { + return ( + +
+ + + + + + + + + + + + + + + + + + +
+
+ ); +} + +function Variant(props: { + label: string; + prefillMessage?: string; + context?: NebulaContext; + isStreaming: boolean; + showContextSelector: boolean; + connectedWallets: WalletMeta[]; + activeAccountAddress: string | undefined; + isConnectingWallet?: boolean; + allowImageUpload?: boolean; +}) { + const [context, setContext] = useState( + props.context, + ); + + return ( + + {}} + allowImageUpload={ + props.allowImageUpload === undefined ? true : props.allowImageUpload + } + client={storybookThirdwebClient} + connectedWallets={props.connectedWallets} + context={context} + isChatStreaming={props.isStreaming} + isConnectingWallet={props.isConnectingWallet || false} + onLoginClick={undefined} + placeholder={"Ask thirdweb AI"} + prefillMessage={props.prefillMessage} + sendMessage={() => {}} + setActiveWallet={(wallet) => { + setContext({ + chainIds: context?.chainIds || [], + networks: context?.networks || null, + walletAddress: wallet.address, + }); + }} + setContext={setContext} + showContextSelector={props.showContextSelector} + /> + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.stories.tsx new file mode 100644 index 00000000000..83f332ed7d9 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.stories.tsx @@ -0,0 +1,239 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ConnectButton, ThirdwebProvider } from "thirdweb/react"; +import { projectStub, randomLorem } from "@/storybook/stubs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { type ChatMessage, Chats } from "./Chats"; + +const meta = { + component: Variant, + decorators: [ + (Story) => ( + +
+
+ +
+ +
+
+ ), + ], + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/Chats", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const UserPresenceError: Story = { + args: { + messages: [ + { + content: [ + { + text: randomLorem(10), + type: "text", + }, + ], + type: "user", + }, + { + texts: [randomLorem(20)], + type: "presence", + }, + { + text: randomLorem(20), + type: "error", + }, + ], + }, +}; + +export const SendTransaction: Story = { + args: { + messages: [ + { + request_id: undefined, + text: randomLorem(40), + type: "assistant", + }, + { + data: { + chain_id: 1, + data: "0x", + to: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", + value: "0x16345785d8a0000", + }, + request_id: "xxxxx", + subtype: "sign_transaction", + type: "action", + }, + ], + }, +}; + +export const WithAndWithoutRequestId: Story = { + args: { + messages: [ + { + request_id: "xxxxx", + text: randomLorem(40), + type: "assistant", + }, + { + request_id: undefined, + text: randomLorem(50), + type: "assistant", + }, + ], + }, +}; + +const markdownExample = `\ +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6 + + +This a paragraph + +This is another paragraph + +This a very long paragraph lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula. +ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec pur us. Donec euismod, nunc nec vehicula. + + +## Empasis + +*Italic text* +_Also italic text_ + +**Bold text** +__Also bold text__ + +***Bold and italic*** +___Also bold and italic___ + +## Blockquote +> This is a blockquote. + +## Lists + +### Unordered list +- Item 1 +- Item 2 + - Nested Item 1 + - Nested Item 2 + +### Ordered list +1. First item +2. Second item + 1. Sub-item 1 + 2. Sub-item 2 + +### Mixed Nested lists + +- Item 1 +- Item 2 + 1. Sub-item 1 + 2. Sub-item 2 + + +1. First item +2. Second item + - Sub-item 1 + - Sub-item 2 + +### Code +This a a paragraph with some \`inlineCode()\` + +This a \`const longerCodeSnippet = "Example. This should be able to handle line breaks as well, it should not be overflowing the page";\` + +\`\`\`javascript +// Code block with syntax highlighting +function example() { + console.log("Hello, world!"); +} +\`\`\` + +### Links +[thirdweb](https://www.thirdweb.com) + +### Images +![Alt text](https://picsum.photos/2000/500) + + +### Horizontal Rule + + + +--- + + + +### Tables +| Header 1 | Header 2 | Header 3 | +|----------|----------|----------| +| Row 1 | Data | More Data| +| Row 2 | Data | More Data| + + +| Column 1 | Column 2 | Column 3 | Column 4 | Column 5 | +|--------------|------------------|--------------|------------------|--------------| +| Row 1 Cell 1 | Row 1 Cell 2 | Row 1 Cell 3 | Row 1 Cell 4 | Row 1 Cell 5 | +| Row 2 Cell 1 | Row 2 Cell 2 | Row 2 Cell 3 | Row 2 Cell 4 | Row 2 Cell 5 | +| Row 3 Cell 1 | Row 3 Cell 2 | Row 3 Cell 3 | Row 3 Cell 4 | Row 3 Cell 5 | +| Row 4 Cell 1 | Row 4 Cell 2 | Row 4 Cell 3 | Row 4 Cell 4 | Row 4 Cell 5 | +| Row 5 Cell 1 | Row 5 Cell 2 | Row 5 Cell 3 | Row 5 Cell 4 | Row 5 Cell 5 | + +`; + +const responseWithCodeMarkdown = ` +${randomLorem(20)} + +${markdownExample} +`; + +export const Markdown: Story = { + args: { + messages: [ + { + request_id: undefined, + text: responseWithCodeMarkdown, + type: "assistant", + }, + { + content: [ + { + text: responseWithCodeMarkdown, + type: "text", + }, + ], + type: "user", + }, + ], + }, +}; + +function Variant(props: { messages: ChatMessage[] }) { + return ( + {}} + sessionId="xxxxx" + setEnableAutoScroll={() => {}} + /> + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx new file mode 100644 index 00000000000..f5c41ed3440 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Chats.tsx @@ -0,0 +1,423 @@ +import { MarkdownRenderer } from "@workspace/ui/components/markdown-renderer"; +import { ScrollShadow } from "@workspace/ui/components/scroll-shadow"; +import { AlertCircleIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import type { Project } from "@/api/project/projects"; +import { NebulaIcon } from "@/icons/NebulaIcon"; +import { cn } from "@/lib/utils"; +import type { + NebulaSwapData, + NebulaTxData, + NebulaUserMessage, + NebulaUserMessageContent, +} from "../api/types"; +import { ExecuteTransactionCard } from "./ExecuteTransactionCard"; +import { MessageActions } from "./MessageActions"; +import { NebulaImage } from "./NebulaImage"; +import { Reasoning } from "./Reasoning/Reasoning"; +import { ApproveTransactionCard, SwapTransactionCard } from "./Swap/SwapCards"; + +export type ChatMessage = + | { + type: "user"; + content: NebulaUserMessageContent; + } + | { + text: string; + type: "error"; + } + | { + texts: string[]; + type: "presence"; + } + | { + // assistant type message loaded from history doesn't have request_id + request_id: string | undefined; + text: string; + type: "assistant"; + } + | { + type: "action"; + subtype: "sign_transaction"; + request_id: string; + data: NebulaTxData; + } + | { + type: "action"; + subtype: "sign_swap"; + request_id: string; + data: NebulaSwapData; + } + | { + type: "image"; + request_id: string; + data: { + width: number; + height: number; + url: string; + }; + }; + +export function Chats(props: { + project: Project; + messages: Array; + isChatStreaming: boolean; + authToken: string; + sessionId: string | undefined; + className?: string; + client: ThirdwebClient; + setEnableAutoScroll: (enable: boolean) => void; + enableAutoScroll: boolean; + useSmallText?: boolean; + sendMessage: (message: NebulaUserMessage) => void; +}) { + const { messages, setEnableAutoScroll, enableAutoScroll } = props; + const scrollAnchorRef = useRef(null); + const chatContainerRef = useRef(null); + + // auto scroll to bottom when messages change + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!enableAutoScroll || messages.length === 0) { + return; + } + scrollAnchorRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, enableAutoScroll]); + + // stop auto scrolling when user interacts with chat + // eslint-disable-next-line no-restricted-syntax + 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); + }, [setEnableAutoScroll, enableAutoScroll]); + + return ( +
+ +
+
+ {props.messages.map((message, index) => { + const isMessagePending = + props.isChatStreaming && index === props.messages.length - 1; + + const shouldHideMessage = + message.type === "user" && + message.content.every((msg) => msg.type === "transaction"); + + if (shouldHideMessage) { + return null; + } + + return ( +
+ +
+ ); + })} +
+
+
+ +
+ ); +} + +function RenderMessage(props: { + message: ChatMessage; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: NebulaUserMessage) => void; + nextMessage: ChatMessage | undefined; + authToken: string; + sessionId: string | undefined; + project: Project; +}) { + const { message } = props; + + if (props.message.type === "user") { + return ( +
+ {props.message.content.map((msg, index) => { + if (msg.type === "text") { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: TODO +
+
+ +
+
+ ); + } + + if (msg.type === "image") { + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: TODO +
+ +
+ ); + } + + if (msg.type === "transaction") { + return null; + } + + return null; + })} +
+ ); + } + + return ( +
+ {/* Left Icon */} +
+
+ {message.type === "presence" && ( + + )} + + {message.type === "assistant" && ( + + )} + + {message.type === "error" && ( + + )} +
+
+ + {/* Right Message */} +
+ + + + + {/* message feedback */} + {message.type === "assistant" && + !props.isMessagePending && + props.sessionId && + message.request_id && ( + + )} +
+
+ ); +} + +function RenderResponse(props: { + message: ChatMessage; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: NebulaUserMessage) => void; + nextMessage: ChatMessage | undefined; + sessionId: string | undefined; + authToken: string; + project: Project; +}) { + const { message, isMessagePending, client, sendMessage, nextMessage } = props; + + switch (message.type) { + case "assistant": + return ( + + ); + + case "presence": + return ; + + case "error": + return ( +
+ {message.text} +
+ ); + case "image": { + return ( + + ); + } + + case "action": { + if (message.subtype === "sign_transaction") { + return ( + { + // do not send automatic prompt if there is another transaction after this one + if (nextMessage?.type === "action") { + return; + } + + sendMessage({ + content: [ + { + chain_id: message.data.chain_id, + transaction_hash: txHash, + type: "transaction", + }, + ], + role: "user", + }); + }} + txData={message.data} + /> + ); + } + + if (message.subtype === "sign_swap") { + if (message.data.action === "approval") { + return ( + + ); + } + + return ( + { + // do not send automatic prompt if there is another transaction after this one + if (nextMessage?.type === "action") { + return; + } + + sendMessage({ + content: [ + { + chain_id: message.data.transaction.chain_id, + transaction_hash: txHash, + type: "transaction", + }, + ], + role: "user", + }); + }} + swapData={message.data} + /> + ); + } + + return null; + } + + case "user": { + return null; + } + } + + return null; +} + +function StyledMarkdownRenderer(props: { + text: string; + isMessagePending: boolean; + type: "assistant" | "user"; +}) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.stories.tsx new file mode 100644 index 00000000000..f4520f02ceb --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ThirdwebProvider } from "thirdweb/react"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { EmptyStateChatPageContent } from "./EmptyStateChatPageContent"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/EmptyStateChatPage", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + prefillMessage: undefined, + }, +}; + +export const PrefilledMessage: Story = { + args: { + prefillMessage: "This is a prefilled message", + }, +}; + +function Story(props: { prefillMessage: string | undefined }) { + return ( + +
+ {}} + setActiveWallet={() => {}} + setContext={() => {}} + showAurora={false} + client={storybookThirdwebClient} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.tsx new file mode 100644 index 00000000000..9f5003c4c9c --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/EmptyStateChatPageContent.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { ArrowUpRightIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import { Button } from "@/components/ui/button"; +import { NebulaIcon } from "@/icons/NebulaIcon"; +import { cn } from "@/lib/utils"; +import type { NebulaContext, NebulaUserMessage } from "../api/types"; +import { examplePrompts } from "../data/examplePrompts"; +import { ChatBar, type WalletMeta } from "./ChatBar"; + +export function EmptyStateChatPageContent(props: { + sendMessage: (message: NebulaUserMessage) => void; + prefillMessage: string | undefined; + context: NebulaContext | undefined; + setContext: (context: NebulaContext | undefined) => void; + connectedWallets: WalletMeta[]; + setActiveWallet: (wallet: WalletMeta) => void; + isConnectingWallet: boolean; + showAurora: boolean; + allowImageUpload: boolean; + onLoginClick: undefined | (() => void); + client: ThirdwebClient; +}) { + return ( +
+ {props.showAurora && ( + + )} +
+ +
+
+
+ +
+
+
+
+

+ How can I help you
onchain today? +

+
+
+ { + // the page will switch so, no need to handle abort here + }} + allowImageUpload={props.allowImageUpload} + client={props.client} + connectedWallets={props.connectedWallets} + context={props.context} + isChatStreaming={false} + isConnectingWallet={props.isConnectingWallet} + onLoginClick={props.onLoginClick} + placeholder={"Ask thirdweb AI"} + prefillMessage={props.prefillMessage} + sendMessage={props.sendMessage} + setActiveWallet={props.setActiveWallet} + setContext={props.setContext} + showContextSelector={true} + /> +
+
+ {examplePrompts.map((prompt) => { + return ( + + props.sendMessage({ + content: [{ text: prompt.message, type: "text" }], + role: "user", + }) + } + /> + ); + })} +
+
+
+
+ ); +} + +function ExamplePrompt(props: { label: string; onClick: () => void }) { + return ( + + ); +} + +function FancyBorders() { + return ( + <> + {/* fancy borders */} +
+ {/* top */} + + {/* bottom */} + + {/* left */} + + {/* right */} + +
+ + ); +} + +function DashedBgDiv(props: { + className?: string; + type: "horizontal" | "vertical"; +}) { + return ( +
+ ); +} + +type AuroraProps = { + className?: string; +}; + +const Aurora: React.FC = ({ className }) => { + return ( +
+ ); +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx new file mode 100644 index 00000000000..d2d1b62220f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { ConnectButton, ThirdwebProvider } from "thirdweb/react"; +import { + BadgeContainer, + storybookLog, + storybookThirdwebClient, +} from "@/storybook/utils"; +import { ExecuteTransactionCardLayout } from "./ExecuteTransactionCard"; +import type { TxStatus } from "./Swap/common"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/actions/ExecuteTransactionCard", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Variants: Story = { + args: {}, +}; + +const exampleTxHash = + "0xbe81f5a6421625052214b41bb79d1d82751b29aa5639b54d120f00531bb8bcf"; + +function Story() { + return ( + +
+
+ +
+ + + + + +
+
+ ); +} + +function Variant(props: { label: string; status: TxStatus }) { + const [status, setStatus] = useState(props.status); + return ( + + { + storybookLog(`onTxSettled called with ${txHash}`); + }} + sendTx={async () => {}} + setStatus={setStatus} + status={status} + txData={{ + chain_id: 1, + data: "0x", // thirdweb.eth + to: "0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675", + value: "0x16345785d8a0000", // 0.1 eth + }} + /> + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx new file mode 100644 index 00000000000..2b05ec842f1 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/ExecuteTransactionCard.tsx @@ -0,0 +1,164 @@ +import { ArrowRightLeftIcon } from "lucide-react"; +import { + type PreparedTransaction, + prepareTransaction, + type ThirdwebClient, + toEther, +} from "thirdweb"; +import { useActiveAccount } from "thirdweb/react"; +import { WalletAddress } from "@/components/blocks/wallet-address"; +import { TransactionButton } from "@/components/tx-button"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import type { NebulaTxData } from "../api/types"; +import { + TxHashRow, + type TxStatus, + TxStatusRow, + useTxSetup, +} from "./Swap/common"; + +export function ExecuteTransactionCard(props: { + txData: NebulaTxData; + client: ThirdwebClient; + onTxSettled: (txHash: string) => void; +}) { + const { status, setStatus, sendTx } = useTxSetup(); + + return ( + + ); +} + +export function ExecuteTransactionCardLayout(props: { + txData: NebulaTxData; + client: ThirdwebClient; + status: TxStatus; + setStatus: (status: TxStatus) => void; + onTxSettled: (txHash: string) => void; + sendTx: ( + tx: PreparedTransaction, + onTxSettled: (txHash: string) => void, + ) => Promise; +}) { + const { txData } = props; + const chain = useV5DashboardChain(txData.chain_id); + const account = useActiveAccount(); + + return ( +
+
+ {/* header */} +

+ Transaction +

+ + {/* content */} +
+ {/* From */} +
+ From + {account ? ( + + ) : ( + Your Wallet + )} +
+ + {/* To */} + {txData.to && ( +
+ To + + +
+ )} + + {/* Value */} + {txData.value !== undefined && ( +
+ Value + {toEther(BigInt(txData.value))} {chain.nativeCurrency?.symbol} +
+ )} + + {/* Network */} +
+ Network +
+ + + {chain.name || `Chain ID: ${txData.chain_id}`} + +
+
+ + {/* Status */} + {props.status.type !== "idle" && ( + + )} + + {/* Transaction Hash */} + {"txHash" in props.status && props.status.txHash && ( + + )} +
+ + {/* footer */} + {props.status.type !== "confirmed" && ( +
+ { + const tx = prepareTransaction({ + chain: chain, + client: props.client, + data: txData.data, + to: txData.to, + value: txData.value ? BigInt(txData.value) : undefined, + }); + + props.sendTx(tx, props.onTxSettled); + }} + size="sm" + transactionCount={undefined} + txChainID={txData.chain_id} + variant="default" + > + + Execute Transaction + +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/MessageActions.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/MessageActions.tsx new file mode 100644 index 00000000000..ed2ca082bba --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/MessageActions.tsx @@ -0,0 +1,126 @@ +import { useMutation } from "@tanstack/react-query"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { + CheckIcon, + CopyIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { submitFeedback } from "../api/feedback"; + +export function MessageActions(props: { + project: Project; + requestId: string; + sessionId: string; + messageText: string | undefined; + className?: string; + buttonClassName?: string; +}) { + const [isCopied, setIsCopied] = useState(false); + function sendRating(rating: "good" | "bad") { + return submitFeedback({ + project: props.project, + rating, + requestId: props.requestId, + sessionId: props.sessionId, + }); + } + const sendPositiveRating = useMutation({ + mutationFn: () => sendRating("good"), + onError() { + toast.error("Failed to send feedback", { + position: "top-right", + }); + }, + onSuccess() { + toast.info("Thanks for the feedback!", { + position: "top-right", + }); + }, + }); + + const sendBadRating = useMutation({ + mutationFn: () => sendRating("bad"), + onError() { + toast.error("Failed to send feedback", { + position: "top-right", + }); + }, + onSuccess() { + toast.info("Thanks for the feedback!", { + position: "top-right", + }); + }, + }); + + const { messageText } = props; + + return ( +
+ {messageText && ( + + )} + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaConnectButton.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaConnectButton.tsx new file mode 100644 index 00000000000..b1720bb6564 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaConnectButton.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Spinner } from "@workspace/ui/components/spinner"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTheme } from "next-themes"; +import type { ThirdwebClient } from "thirdweb"; +import { + ConnectButton, + useActiveAccount, + useActiveWalletConnectionStatus, +} from "thirdweb/react"; +import { Button } from "@/components/ui/button"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { cn } from "@/lib/utils"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; + +export const NebulaConnectWallet = (props: { + client: ThirdwebClient; + connectButtonClassName?: string; + signInLinkButtonClassName?: string; + detailsButtonClassName?: string; + customDetailsButton?: (address: string) => React.ReactElement; +}) => { + const { theme } = useTheme(); + const t = theme === "light" ? "light" : "dark"; + const { allChainsV5 } = useAllChainsData(); + const pathname = usePathname(); + const account = useActiveAccount(); + const connectionStatus = useActiveWalletConnectionStatus(); + + if (connectionStatus === "connecting") { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + const { customDetailsButton } = props; + return ( + customDetailsButton(account.address) + : undefined, + }} + // we have an AutoConnect already added in root layout with AA configuration + theme={getSDKTheme(t)} + /> + ); +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx new file mode 100644 index 00000000000..41a2bca9bd7 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaImage.tsx @@ -0,0 +1,126 @@ +import { useMutation } from "@tanstack/react-query"; +import { Spinner } from "@workspace/ui/components/spinner"; +import { ArrowDownToLineIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import type { Project } from "@/api/project/projects"; +import { Img } from "@/components/blocks/Img"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler"; +import { MessageActions } from "./MessageActions"; + +export function NebulaImage( + props: + | { + project: Project; + type: "response"; + url: string; + width: number; + height: number; + client: ThirdwebClient; + requestId: string; + sessionId: string | undefined; + } + | { + project: Project; + type: "submitted"; + url: string; + client: ThirdwebClient; + }, +) { + const src = props.url.startsWith("ipfs://") + ? resolveSchemeWithErrorHandler({ + client: props.client, + uri: props.url, + }) + : props.url; + + const downloadMutation = useMutation({ + mutationFn: () => downloadImage(src || ""), + }); + + if (!src) { + return null; + } + + return ( +
+ + + + } + src={src} + width={props.type === "response" ? props.width : undefined} + /> + + + + Image + } + src={src} + /> + + + +
+ +
+ + {props.type === "response" && props.sessionId && ( +
+ +
+ )} +
+ ); +} + +async function downloadImage(src: string) { + try { + const response = await fetch(src, { mode: "cors" }); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "image.png"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error("Download failed:", error); + } +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx new file mode 100644 index 00000000000..63b971bf520 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/NebulaMobileNav.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { MenuIcon } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import type { Project } from "@/api/project/projects"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import type { TruncatedSessionInfo } from "../api/types"; +import { ChatSidebar } from "./ChatSidebar"; + +export function MobileNav(props: { + sessions: TruncatedSessionInfo[]; + authToken: string; + client: ThirdwebClient; + team_slug: string; + project: Project; +}) { + const [isOpen, setIsOpen] = useState(false); + const newChatPage = `/team/${props.team_slug}/${props.project.slug}/ai`; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.stories.tsx new file mode 100644 index 00000000000..b215f54db35 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { randomLorem } from "@/storybook/stubs"; +import { Reasoning } from "./Reasoning"; + +const meta = { + component: Story, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + title: "AI/Reasoning", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Pending: Story = { + args: {}, +}; + +function Story() { + return ( +
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.tsx new file mode 100644 index 00000000000..be0bf2e90f6 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Reasoning/Reasoning.tsx @@ -0,0 +1,62 @@ +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { TextShimmer } from "@/components/ui/text-shimmer"; +import { cn } from "@/lib/utils"; + +export function Reasoning(props: { isPending: boolean; texts: string[] }) { + const [_isOpen, setIsOpen] = useState(false); + const isOpen = props.isPending ? true : _isOpen; + const showAll = !props.isPending; + const lastText = props.texts[props.texts.length - 1]; + + return ( + + + + {isOpen && ( + <> + {showAll && props.texts.length > 0 && ( +
    + {props.texts.map((text) => ( +
  • + {text.trim()} +
  • + ))} +
+ )} + + {!showAll && lastText && ( +
+ {lastText.trim()} +
+ )} + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.stories.tsx new file mode 100644 index 00000000000..81c45c92c65 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; +import { ConnectButton, ThirdwebProvider } from "thirdweb/react"; +import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils"; +import type { NebulaSwapData } from "../../api/types"; +import type { TxStatus } from "./common"; +import { + ApproveTransactionCardLayout, + SwapTransactionCardLayout, +} from "./SwapCards"; + +const meta = { + component: Story, + parameters: { + nextjs: { + appDirectory: true, + }, + }, + title: "AI/actions/Swap Cards", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const swapBaseData: Omit = { + from_token: { + address: "0x4200000000000000000000000000000000000006", + amount: "100000000000000000", + chain_id: 1, + decimals: 18, + symbol: "WETH", + }, + intent: { + amount: "100000000000000000", + destination_chain_id: 8453, + destination_token_address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + origin_chain_id: 8453, + origin_token_address: "0x4200000000000000000000000000000000000006", + receiver: "0x1E68D1dB85f3F4e1cc8dd816709C529139f79290", + sender: "0x1E68D1dB85f3F4e1cc8dd816709C529139f79290", + }, + to_token: { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "176443798", + chain_id: 8453, + decimals: 6, + symbol: "USDC", + }, + transaction: { + chain_id: 8453, + data: "0x095ea7b3000000000000000000000000f8ab2dbe6c43bf1a856471182290f91d621ba76d000000000000000000000000000000000000000000000000016345785d8a0000", + to: "0x4200000000000000000000000000000000000006", + }, +}; + +export const Swap: Story = { + args: { + swapData: { + ...swapBaseData, + action: "sell", + }, + }, +}; + +export const Approve: Story = { + args: { + swapData: { + ...swapBaseData, + action: "approval", + }, + }, +}; + +const exampleTxHash = + "0xbe81f5a6421625052214b41bb79d1d82751b29aa5639b54d120f00531bb8bcf"; + +function Story(props: { swapData: NebulaSwapData }) { + return ( + +
+
+ +
+ + + + + +
+
+ ); +} + +function Variant(props: { + label: string; + status: TxStatus; + swapData: NebulaSwapData; +}) { + const [status, setStatus] = useState(props.status); + return ( + + {props.swapData.action === "approval" ? ( + {}} + setStatus={setStatus} + status={status} + swapData={props.swapData} + /> + ) : ( + {}} + setStatus={setStatus} + status={status} + swapData={props.swapData} + /> + )} + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx new file mode 100644 index 00000000000..46fc844bb66 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/SwapCards.tsx @@ -0,0 +1,280 @@ +import { ArrowRightLeftIcon, CheckIcon } from "lucide-react"; +import { + getAddress, + NATIVE_TOKEN_ADDRESS, + type PreparedTransaction, + prepareTransaction, + type ThirdwebClient, + toTokens, +} from "thirdweb"; +import { TransactionButton } from "@/components/tx-button"; +import { useV5DashboardChain } from "@/hooks/chains/v5-adapter"; +import { ChainIconClient } from "@/icons/ChainIcon"; +import type { NebulaSwapData } from "../../api/types"; +import { TxHashRow, type TxStatus, TxStatusRow, useTxSetup } from "./common"; + +export function SwapTransactionCard(props: { + swapData: NebulaSwapData; + client: ThirdwebClient; + onTxSettled: (txHash: string) => void; +}) { + const { status, setStatus, sendTx } = useTxSetup(); + + return ( + sendTx(tx, props.onTxSettled)} + setStatus={setStatus} + status={status} + swapData={props.swapData} + /> + ); +} + +export function SwapTransactionCardLayout(props: { + swapData: NebulaSwapData; + client: ThirdwebClient; + status: TxStatus; + setStatus: (status: TxStatus) => void; + sendTx: (tx: PreparedTransaction) => Promise; +}) { + const { swapData } = props; + const txChain = useV5DashboardChain(swapData.transaction.chain_id); + + const isSellingNativeToken = + getAddress(swapData.from_token.address) === + getAddress(NATIVE_TOKEN_ADDRESS); + + return ( +
+
+ {/* header */} +
+

+ Swap +

+
+ + {/* content */} +
+ + + + + {/* Status */} + {props.status.type !== "idle" && ( + + )} + + {/* Transaction Hash */} + {"txHash" in props.status && props.status.txHash && ( + + )} +
+ + {/* footer */} + {props.status.type !== "confirmed" && ( +
+ { + const tx = prepareTransaction({ + chain: txChain, + client: props.client, + data: swapData.transaction.data, + value: swapData.transaction.value + ? BigInt(swapData.transaction.value) + : undefined, + extraGas: 50000n, + erc20Value: isSellingNativeToken + ? undefined + : { + amountWei: BigInt(swapData.from_token.amount), + tokenAddress: swapData.from_token.address, + }, + to: swapData.transaction.to, + }); + + props.sendTx(tx); + }} + size="sm" + transactionCount={undefined} + txChainID={swapData.transaction.chain_id} + variant="default" + > + + Swap Tokens + +
+ )} +
+
+ ); +} + +export function ApproveTransactionCard(props: { + swapData: NebulaSwapData; + client: ThirdwebClient; +}) { + const { status, setStatus, sendTx } = useTxSetup(); + + return ( + sendTx(tx, undefined)} + setStatus={setStatus} + status={status} + swapData={props.swapData} + /> + ); +} + +export function ApproveTransactionCardLayout(props: { + swapData: NebulaSwapData; + client: ThirdwebClient; + status: TxStatus; + setStatus: (status: TxStatus) => void; + sendTx: (tx: PreparedTransaction) => Promise; +}) { + const { swapData } = props; + const txChain = useV5DashboardChain(swapData.transaction.chain_id); + + const isTransactionPending = + props.status.type === "sending" || props.status.type === "confirming"; + + return ( +
+
+ {/* header */} +
+

+ Approve +

+

+ Approve spending to swap tokens on your behalf +

+
+ + {/* content */} +
+ + + + + {/* Status */} + {props.status.type !== "idle" && ( + + )} + + {/* Transaction Hash */} + {"txHash" in props.status && props.status.txHash && ( + + )} +
+ + {/* footer */} + {props.status.type !== "confirmed" && ( +
+ { + const tx = prepareTransaction({ + chain: txChain, + client: props.client, + data: swapData.transaction.data, + to: swapData.transaction.to, + }); + + props.sendTx(tx); + }} + size="sm" + transactionCount={undefined} + txChainID={swapData.transaction.chain_id} + variant="default" + > + + Approve + +
+ )} +
+
+ ); +} + +function TokenRow(props: { + amount: string; + symbol: string; + chainId: number; + client: ThirdwebClient; + title: string; + decimals: number; +}) { + const chain = useV5DashboardChain(props.chainId); + const tokenDisplayValue = toTokens(BigInt(props.amount), props.decimals); + return ( +
+
+
{props.title}
+
+ {tokenDisplayValue} {props.symbol} +
+
+
+
+ +
{chain.name}
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/common.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/common.tsx new file mode 100644 index 00000000000..af22bbc1238 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/Swap/common.tsx @@ -0,0 +1,170 @@ +import { Spinner } from "@workspace/ui/components/spinner"; +import { CircleCheckIcon, CircleXIcon, ExternalLinkIcon } from "lucide-react"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { useCallback, useState } from "react"; +import { type PreparedTransaction, waitForReceipt } from "thirdweb"; +import { useSendTransaction } from "thirdweb/react"; +import { Button } from "@/components/ui/button"; +import { CopyTextButton } from "@/components/ui/CopyTextButton"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { cn } from "@/lib/utils"; +import { getSDKTheme } from "@/utils/sdk-component-theme"; + +export type TxStatus = + | { + type: "idle"; + } + | { + type: "sending"; + } + | { + type: "confirming"; + txHash: string; + } + | { + type: "confirmed"; + txHash: string; + } + | { + type: "failed"; + txHash: string | undefined; + }; + +export function TxStatusRow(props: { status: TxStatus }) { + return ( +
+ Status +
+ + {/* icon */} + {(props.status.type === "sending" || + props.status.type === "confirming") && ( + + )} + + {props.status.type === "confirmed" && ( + + )} + + {props.status.type === "failed" && } + + {/* text */} + + {props.status.type === "sending" && "Sending Transaction"} + {props.status.type === "confirming" && "Waiting for Confirmation"} + {props.status.type === "confirmed" && "Transaction Confirmed"} + {props.status.type === "failed" && "Transaction Failed"} + + +
+
+ ); +} + +export function TxHashRow(props: { chainId: number; txHash: string }) { + const { idToChain } = useAllChainsData(); + const chainMetadata = idToChain.get(props.chainId); + const explorer = chainMetadata?.explorers?.[0]?.url; + + return ( +
+ + Transaction Hash + +
+ {explorer ? ( + + ) : ( + + )} +
+
+ ); +} + +export function useTxSetup() { + const [status, setStatus] = useState({ type: "idle" }); + const { theme } = useTheme(); + + const sendTransaction = useSendTransaction({ + payModal: { + theme: getSDKTheme(theme === "light" ? "light" : "dark"), + }, + }); + + const sendTx = useCallback( + async ( + tx: PreparedTransaction, + onTxSettled: ((txHash: string) => void) | undefined, + ) => { + let txHash: string | undefined; + + try { + // submit transaction + setStatus({ type: "sending" }); + const submittedReceipt = await sendTransaction.mutateAsync(tx); + txHash = submittedReceipt.transactionHash; + + // wait for receipt + setStatus({ + txHash: submittedReceipt.transactionHash, + type: "confirming", + }); + + const confirmReceipt = await waitForReceipt(submittedReceipt); + txHash = confirmReceipt.transactionHash; + setStatus({ + txHash: confirmReceipt.transactionHash, + type: "confirmed", + }); + + onTxSettled?.(txHash); + } catch (e) { + console.error(e); + if (txHash) { + onTxSettled?.(txHash); + } + setStatus({ + txHash: txHash, + type: "failed", + }); + } + }, + [sendTransaction], + ); + + return { + sendTx, + setStatus, + status, + }; +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.stories.tsx new file mode 100644 index 00000000000..ba08cc71f8a --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { storybookThirdwebClient } from "@/storybook/utils"; +import { + TransactionSectionUI, + type WalletTransaction, +} from "./TransactionsSection"; + +const meta = { + component: TransactionSectionUI, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + title: "AI/TransactionsSection", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const transactionsStub: WalletTransaction[] = [ + { + chain_id: "8453", + decoded: { + inputs: { + to: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425", + value: "10000", + }, + name: "transfer", + signature: "transfer(address,uint256)", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0x2a098695dcfa32a67ec115af7c8da1ef6f443ea72baf7e49525204dd521a985e", + to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + value: "0", + }, + { + chain_id: "8453", + decoded: { + inputs: null, + name: "", + signature: "", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xc521bfa0ba3e68fa1a52c67f93a8e215d3ade0b45956ba215390bcc0576202f1", + to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425", + value: "1000000000000000", + }, + { + chain_id: "8453", + decoded: { + inputs: null, + name: "", + signature: "", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xf2d92059c9ea425ccf7568bfe2589b3c7e45b108b5af658ec79c2f2d3723e410", + to_address: "0x83dd93fa5d8343094f850f90b3fb90088c1bb425", + value: "1000000000000000", + }, + { + chain_id: "8453", + decoded: { + inputs: { + to: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + value: "100000", + }, + name: "transfer", + signature: "transfer(address,uint256)", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xea3da430876c09acfa665450a91edb99fe8dc018864c5dfa3ac53bf265ce8d66", + to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + value: "0", + }, + { + chain_id: "8453", + decoded: { + inputs: { + spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d", + value: "10000000", + }, + name: "approve", + signature: "approve(address,uint256)", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xad03e5b350645f2e4cdd066c30b2f6b708aa34bd4d1c5ca20fd81ecfe1656164", + to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + value: "0", + }, + { + chain_id: "8453", + decoded: { + inputs: {}, + name: "initiateTransaction", + signature: + "initiateTransaction((bytes32,address,uint256,address,address,uint256,address,uint256,bytes,bytes),bytes)", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xd3106aaa4b7e530ac7530c8ea4984eec52670aabaf5969ae6bc8d8246e74c3c0", + to_address: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d", + value: "0", + }, + { + chain_id: "8453", + decoded: { + inputs: { + spender: "0xf8ab2dbe6c43bf1a856471182290f91d621ba76d", + value: "1000000", + }, + name: "approve", + signature: "approve(address,uint256)", + }, + from_address: "0x1f846f6dae38e1c88d71eaa191760b15f38b7a37", + hash: "0xd284b6e0dd938b4610ff1877c1d4692a8d10d83ec6be24789dc87e1ef4aa4756", + to_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + value: "0", + }, +]; + +export const MultipleAssets: Story = { + args: { + client: storybookThirdwebClient, + data: transactionsStub, + isPending: false, + }, +}; + +export const SingleAsset: Story = { + args: { + client: storybookThirdwebClient, + data: transactionsStub.slice(0, 1), + isPending: false, + }, +}; + +export const EmptyAssets: Story = { + args: { + client: storybookThirdwebClient, + data: [], + isPending: false, + }, +}; + +export const Loading: Story = { + args: { + client: storybookThirdwebClient, + data: [], + isPending: true, + }, +}; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx new file mode 100644 index 00000000000..446f63fddcf --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/components/TransactionsSection/TransactionsSection.tsx @@ -0,0 +1,210 @@ +import { useQuery } from "@tanstack/react-query"; +import { XIcon } from "lucide-react"; +import Link from "next/link"; +import type { ThirdwebClient } from "thirdweb"; +import { useActiveAccount, useActiveWalletChain } from "thirdweb/react"; +import { shortenAddress } from "thirdweb/utils"; +import { Skeleton } from "@/components/ui/skeleton"; +import { isProd } from "@/constants/env-utils"; +import { useAllChainsData } from "@/hooks/chains/allChains"; +import { ChainIconClient } from "@/icons/ChainIcon"; + +// Note: this is not the full object type returned from insight API, it only includes fields we care about +export type WalletTransaction = { + chain_id: string; + value: string; + hash: string; + from_address: string; + to_address: string; + decoded?: { + name: string; + signature: string; + inputs: null | object; + }; +}; + +export function TransactionSectionUI(props: { + data: WalletTransaction[]; + isPending: boolean; + client: ThirdwebClient; +}) { + if (props.data.length === 0 && !props.isPending) { + return ( +
+
+ +
+
No Recent Activity
+
+ ); + } + + return ( +
+ {!props.isPending && + props.data.map((asset) => ( + + ))} + + {props.isPending && + new Array(10).fill(null).map((_, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: TODO + + ))} +
+ ); +} + +function SkeletonAssetItem() { + return ( +
+ +
+ + +
+
+ ); +} + +function TransactionInfo(props: { + transaction: WalletTransaction; + client: ThirdwebClient; +}) { + const { idToChain } = useAllChainsData(); + const chainMeta = idToChain.get(Number(props.transaction.chain_id)); + const title = getTransactionTitle(props.transaction); + const description = getTransactionDescription(props.transaction); + const explorer = + chainMeta?.explorers?.[0]?.url || + `https://thirdweb.com/${props.transaction.chain_id}`; + + return ( +
+ + +
+ + {title} + + + {description && ( +

{description}

+ )} +
+
+ ); +} + +function getTransactionTitle(transaction: WalletTransaction) { + if (!transaction.decoded) { + return "Transaction Sent"; + } + + if (transaction.decoded.name) { + return transaction.decoded.name; + } + + if (transaction.decoded.signature) { + const nameFromSignature = transaction.decoded.signature.split("(")[0]; + if (nameFromSignature) { + return nameFromSignature; + } + } + + if (transaction.value !== "0") { + return "Transfer"; + } + + return "Transaction Sent"; +} + +function getTransactionDescription(transaction: WalletTransaction) { + if (!transaction.decoded) { + return `To: ${shortenAddress(transaction.to_address)}`; + } + + if ( + typeof transaction.decoded.inputs === "object" && + transaction.decoded.inputs !== null + ) { + if ( + "to" in transaction.decoded.inputs && + typeof transaction.decoded.inputs.to === "string" + ) { + return `To: ${shortenAddress(transaction.decoded.inputs.to)}`; + } + + if ( + "spender" in transaction.decoded.inputs && + typeof transaction.decoded.inputs.spender === "string" + ) { + return `Spender: ${shortenAddress(transaction.decoded.inputs.spender)}`; + } + } + + return `To: ${shortenAddress(transaction.to_address)}`; +} + +export function TransactionsSection(props: { client: ThirdwebClient }) { + const account = useActiveAccount(); + const activeChain = useActiveWalletChain(); + + const txQuery = useQuery({ + enabled: !!account && !!activeChain, + queryFn: async () => { + if (!account || !activeChain) { + return []; + } + const chains = [...new Set([1, 8453, 10, 137, activeChain.id])]; + const url = new URL( + `https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/wallets/${account.address}/transactions`, + ); + url.searchParams.set("limit", "20"); + url.searchParams.set("decode", "true"); + url.searchParams.set("clientId", props.client.clientId); + + const threeMonthsAgoUnixTime = Math.floor( + (Date.now() - 3 * 30 * 24 * 60 * 60 * 1000) / 1000, + ); + + url.searchParams.set( + "filter_block_timestamp_gte", + `${threeMonthsAgoUnixTime}`, + ); + + for (const chain of chains) { + url.searchParams.append("chain", chain.toString()); + } + + const response = await fetch(url.toString()); + const json = (await response.json()) as { + data?: WalletTransaction[]; + }; + + return json.data ?? []; + }, + queryKey: ["v1/wallets/transactions", account?.address, activeChain?.id], + retry: false, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts new file mode 100644 index 00000000000..30385ff01d3 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/data/examplePrompts.ts @@ -0,0 +1,108 @@ +type ExamplePrompt = { + title: string; + message: string; + interceptedReply?: string; +}; + +const whatCanNebulaDoReply = ` +thirdweb AI is a natural language model with improved blockchain reasoning, autonomous transaction capabilities, and real-time access to the blockchain. +[Learn more about thirdweb AI](https://portal.thirdweb.com/ai/chat) + +Here are some example actions you can perform with thirdweb AI: + +### Bridge & Swap +Bridge and swap native currencies +- Swap 1 USDC to 1 USDT on the Ethereum Mainnet +- Bridge 0.5 ETH from Ethereum Mainnet to Polygon + +### Transfer +Send native and ERC-20 currencies +- Send 0.1 ETH to vitalik.eth +- Transfer 1 USDC to saminacodes.eth on Base + +### Deploy +Deploy published contracts +- Deploy a Token ERC20 Contract with name "Hello World" and description "My Hello Contract" on Ethereum. +- Deploy a Split contract with two recipients. +- Deploy an ERC1155 Contract named 'Hello World' with description 'Hello badges on Ethereum' + +### Understand +Retrieve information about smart contracts. +- What ERC standards are implemented by contract address 0x59325733eb952a92e069C87F0A6168b29E80627f on Ethereum? +- What functions can I use to mint more of my contract's NFTs? +- What is the total supply of NFTs on 0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e? + +### Interact +Query wallet balances, addresses, and token holdings. +- How much ETH is in my wallet? +- What is the wallet address of vitalik.eth? +- Does my wallet hold USDC on Base? + +### Explore +Access blockchain-specific data. +- What is the last block on zkSync? +- What is the current gas price on Avalanche C-Chain? +- Can you show me transaction details for 0xdfc450bb39e44bd37c22e0bfd0e5212edbea571e4e534d87b5cbbf06f10b9e04 on Optimism? + +### Research +Obtain details about tokens, their addresses, and current prices. +- What is the address of USDC on Ethereum? +- Is there a UNI token on Arbitrum? +- What is the current price of ARB? + +### Build +Implement features using Web3 SDKs and tools. +- How can I add a connect wallet button to my web app? I want to support users connecting with both email/social wallets and MetaMask and use smart wallets. +- Can you show me how to claim an NFT from an ERC721 using TypeScript? +- I have an ERC1155 contract from thirdweb. Can you show me how to generate and mint with a signature? +`; + +const deployTokenReply = ` +Let's create your token! Just name it and I'll get started. I'll take care of the rest. + +Add more info for more fun: +- Symbol (e.g. 'HELLO') +- Description (e.g. 'Hello world token deployed by thirdweb AI') +- Total Supply (e.g. 1 million) +- Mint total supply to your wallet (default is true) +- Decimal places (default 18) +- Image URL + +If you want, I can generate an image for it too. +`; + +const buyUsdcReply = ` +Easy. How much USDC? +You can pay with any token, but I'll default to ETH. +`; + +const transferEthReply = ` +Great! How much ETH and to what address? +`; + +export const examplePrompts: ExamplePrompt[] = [ + { + interceptedReply: whatCanNebulaDoReply, + message: "Tell me about thirdweb AI's capabilities.", + title: "What can thirdweb AI do?", + }, + { + interceptedReply: deployTokenReply, + message: "I'd like to deploy a token.", + title: "Launch a Token", + }, + { + interceptedReply: buyUsdcReply, + message: "I want to buy USDC", + title: "Buy USDC", + }, + { + message: "Analyze the Uniswap v3 contracts on Ethereum", + title: "Analyze the Uniswap contracts", + }, + { + interceptedReply: transferEthReply, + message: "I want to send some ETH", + title: "Send ETH to someone", + }, +]; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/hooks/useSessionsWithLocalOverrides.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/hooks/useSessionsWithLocalOverrides.ts new file mode 100644 index 00000000000..4ab019f2772 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/hooks/useSessionsWithLocalOverrides.ts @@ -0,0 +1,25 @@ +import { useStore } from "@/lib/reactive"; +import type { TruncatedSessionInfo } from "../api/types"; +import { deletedSessionsStore, newSessionsStore } from "../stores"; + +export function useSessionsWithLocalOverrides( + _sessions: TruncatedSessionInfo[], +) { + const newAddedSessions = useStore(newSessionsStore); + const deletedSessions = useStore(deletedSessionsStore); + const mergedSessions = [..._sessions]; + + for (const session of newAddedSessions) { + // if adding a new session that has same id as existing session, update the existing session + const index = mergedSessions.findIndex((s) => s.id === session.id); + if (index !== -1) { + mergedSessions[index] = session; + } else { + mergedSessions.unshift(session); + } + } + + return mergedSessions.filter((s) => { + return !deletedSessions.some((d) => d === s.id); + }); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx index 0ccf60dafbe..7e443f856eb 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/page.tsx @@ -1,102 +1,50 @@ -import { redirect } from "next/navigation"; -import { ResponsiveSearchParamsProvider } from "responsive-rsc"; -import { getAuthToken } from "@/api/auth-token"; +import { notFound } from "next/navigation"; +import { getAuthToken, getUserThirdwebClient } from "@/api/auth-token"; import { getProject } from "@/api/project/projects"; -import type { DurationId } from "@/components/analytics/date-range-selector"; -import { ResponsiveTimeFilters } from "@/components/analytics/responsive-time-filters"; -import { ProjectPage } from "@/components/blocks/project-page/project-page"; -import { getClientThirdwebClient } from "@/constants/thirdweb-client.client"; -import { NebulaIcon } from "@/icons/NebulaIcon"; -import { getFiltersFromSearchParams } from "@/lib/time"; -import { loginRedirect } from "@/utils/redirects"; -import { AiAnalytics } from "./analytics/chart"; -import { AiSummary } from "./analytics/chart/Summary"; +import { getSessions } from "./api/session"; +import { ChatPageContent } from "./components/ChatPageContent"; +import { ChatPageLayout } from "./components/ChatPageLayout"; export default async function Page(props: { params: Promise<{ team_slug: string; project_slug: string }>; - searchParams: Promise<{ - from?: string; - to?: string; - type?: string; - interval?: string; - }>; }) { - const [searchParams, params] = await Promise.all([ - props.searchParams, - props.params, - ]); - - const { team_slug, project_slug } = params; + const [params] = await Promise.all([props.params]); - const [project, authToken] = await Promise.all([ - getProject(team_slug, project_slug), + const [authToken, project] = await Promise.all([ getAuthToken(), + getProject(params.team_slug, params.project_slug), ]); if (!authToken) { - loginRedirect(`/team/${team_slug}/${project_slug}/ai`); + notFound(); } if (!project) { - redirect(`/team/${team_slug}`); + notFound(); } - const client = getClientThirdwebClient({ - jwt: authToken, - teamId: project.teamId, - }); + const client = await getUserThirdwebClient({ teamId: project.teamId }); - const defaultRange = "last-30" as DurationId; - const { range, interval } = getFiltersFromSearchParams({ - defaultRange, - from: searchParams.from, - interval: searchParams.interval, - to: searchParams.to, - }); + const sessions = await getSessions({ project }).catch(() => []); return ( - - -
- - - - -
-
-
+ + + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/stores.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/stores.ts new file mode 100644 index 00000000000..11a3327e04f --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/ai/stores.ts @@ -0,0 +1,7 @@ +import { createStore } from "@/lib/reactive"; +import type { TruncatedSessionInfo } from "./api/types"; + +export const newSessionsStore = createStore([]); + +// array of deleted session ids +export const deletedSessionsStore = createStore([]); diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx index 19671a9988e..c9db26ac0a5 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/layout.tsx @@ -19,6 +19,7 @@ export default async function ProjectLayout(props: { params: Promise<{ team_slug: string; project_slug: string }>; }) { const params = await props.params; + const [accountAddress, teams, account, authToken, team, project] = await Promise.all([ getAuthTokenWalletAddress(), diff --git a/packages/service-utils/src/core/services.ts b/packages/service-utils/src/core/services.ts index 189d0483977..ffaead9ae84 100644 --- a/packages/service-utils/src/core/services.ts +++ b/packages/service-utils/src/core/services.ts @@ -42,7 +42,7 @@ export const SERVICE_DEFINITIONS = { description: "Advanced blockchain reasoning and execution capabilities with AI", name: "nebula", - title: "Nebula", + title: "thirdweb AI", }, pay: { // all actions allowed diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 924ce4d9252..35cd7a69fcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ importers: fast-xml-parser: specifier: ^5.2.5 version: 5.2.5 + fetch-event-stream: + specifier: 0.1.5 + version: 0.1.5 fuse.js: specifier: 7.1.0 version: 7.1.0 @@ -515,7 +518,7 @@ importers: version: 9.0.15(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5)) '@storybook/nextjs': specifier: 9.0.15 - version: 9.0.15(next@15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9) + version: 9.0.15(next@15.3.5(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9) '@types/node': specifier: 22.14.1 version: 22.14.1 @@ -1604,7 +1607,7 @@ importers: version: 9.0.15(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5)) '@storybook/nextjs': specifier: 9.0.15 - version: 9.0.15(next@15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9) + version: 9.0.15(next@15.3.5(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9) '@types/react': specifier: 19.1.8 version: 19.1.8 @@ -24181,7 +24184,7 @@ snapshots: - webpack-hot-middleware - webpack-plugin-serve - '@storybook/nextjs@9.0.15(next@15.3.5(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9)': + '@storybook/nextjs@9.0.15(next@15.3.5(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@9.0.15(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(utf-8-validate@6.0.5))(type-fest@4.41.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.99.9)': dependencies: '@babel/core': 7.28.0 '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.0) @@ -29432,8 +29435,8 @@ snapshots: '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.0) eslint-plugin-react: 7.37.5(eslint@8.57.0) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.0) @@ -29452,7 +29455,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -29463,7 +29466,7 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.10.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -29488,18 +29491,18 @@ snapshots: - bluebird - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.8.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -29510,7 +29513,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3