diff --git a/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts b/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts index 01ca266015e..3cbc5bb04ac 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts +++ b/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts @@ -2,6 +2,7 @@ import { NEXT_PUBLIC_NEBULA_URL } from "@/constants/env"; // TODO - copy the source of this library to dashboard import { stream } from "fetch-event-stream"; import type { NebulaTxData } from "../components/Chats"; +import type { NebulaUserMessage } from "./types"; export type NebulaContext = { chainIds: string[] | null; @@ -42,7 +43,7 @@ export type NebulaSwapData = { }; export async function promptNebula(params: { - message: string; + message: NebulaUserMessage; sessionId: string; authToken: string; handleStream: (res: ChatStreamedResponse) => void; @@ -50,7 +51,7 @@ export async function promptNebula(params: { context: undefined | NebulaContext; }) { const body: Record = { - message: params.message, + messages: [params.message], stream: true, session_id: params.sessionId, }; diff --git a/apps/dashboard/src/app/nebula-app/(app)/api/types.ts b/apps/dashboard/src/app/nebula-app/(app)/api/types.ts index 915751d6702..9a8515d7242 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/api/types.ts +++ b/apps/dashboard/src/app/nebula-app/(app)/api/types.ts @@ -3,12 +3,39 @@ type SessionContextFilter = { wallet_address: string | null; }; -export type NebulaSessionHistoryMessage = { - role: "user" | "assistant" | "action" | "image"; - content: string; - timestamp: number; +type NebulaUserMessageContentItem = + | { + type: "image"; + image_url: string; + } + | { + 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; diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx index 9ba26c5606c..98b8bdd82d5 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx @@ -34,6 +34,7 @@ import { import { shortenAddress } from "thirdweb/utils"; import type { Wallet } from "thirdweb/wallets"; import type { NebulaContext } from "../api/chat"; +import type { NebulaUserMessage } from "../api/types"; export type WalletMeta = { walletId: Wallet["id"]; @@ -41,7 +42,7 @@ export type WalletMeta = { }; export function ChatBar(props: { - sendMessage: (message: string) => void; + sendMessage: (message: NebulaUserMessage) => void; isChatStreaming: boolean; abortChatStream: () => void; prefillMessage: string | undefined; @@ -53,11 +54,22 @@ export function ChatBar(props: { connectedWallets: WalletMeta[]; setActiveWallet: (wallet: WalletMeta) => void; isConnectingWallet: boolean; + // TODO - add this option later + // showImageUploader: boolean; }) { const [message, setMessage] = useState(props.prefillMessage || ""); const selectedChainIds = props.context?.chainIds?.map((x) => Number(x)) || []; const firstChainId = selectedChainIds[0]; + function handleSubmit(message: string) { + setMessage(""); + props.sendMessage({ + role: "user", + // TODO - add image here later + content: [{ type: "text", text: message }], + }); + } + return (
{ if (message.trim() === "") return; - setMessage(""); - props.sendMessage(message); + handleSubmit(message); }} > diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx index 1f4f36face7..4de667f4d9a 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx @@ -20,7 +20,11 @@ import { } from "thirdweb/react"; import { type NebulaContext, promptNebula } from "../api/chat"; import { createSession, updateSession } from "../api/session"; -import type { NebulaSessionHistoryMessage, SessionInfo } from "../api/types"; +import type { + NebulaSessionHistoryMessage, + NebulaUserMessage, + SessionInfo, +} from "../api/types"; import { examplePrompts } from "../data/examplePrompts"; import { newSessionsStore } from "../stores"; import { ChatBar, type WalletMeta } from "./ChatBar"; @@ -135,12 +139,14 @@ export function ChatPageContent(props: { }, [contextFilters, props.authToken]); const handleSendMessage = useCallback( - async (message: string) => { + async (message: NebulaUserMessage) => { setUserHasSubmittedMessage(true); setMessages((prev) => [ ...prev, - { text: message, type: "user" }, - // instant loading indicator feedback to user + { + type: "user", + content: message.content, + }, { type: "presence", texts: [], @@ -148,7 +154,10 @@ export function ChatPageContent(props: { ]); // handle hardcoded replies first - const lowerCaseMessage = message.toLowerCase(); + const lowerCaseMessage = message.content + .find((x) => x.type === "text") + ?.text.toLowerCase(); + const interceptedReply = examplePrompts.find( (prompt) => prompt.message.toLowerCase() === lowerCaseMessage, )?.interceptedReply; @@ -175,13 +184,18 @@ export function ChatPageContent(props: { 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) { + if (messages.length === 0 && firstTextMessage) { const prevValue = newSessionsStore.getValue(); newSessionsStore.setValue([ { id: currentSessionId, - title: message, + title: firstTextMessage, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }, @@ -193,7 +207,7 @@ export function ChatPageContent(props: { await handleNebulaPrompt({ abortController, - message, + message: message, sessionId: currentSessionId, authToken: props.authToken, setMessages, @@ -234,7 +248,15 @@ export function ChatPageContent(props: { !hasDoneAutoPrompt.current ) { hasDoneAutoPrompt.current = true; - handleSendMessage(props.initialParams.q); + handleSendMessage({ + role: "user", + content: [ + { + type: "text", + text: props.initialParams.q, + }, + ], + }); } }, [props.initialParams?.q, messages.length, handleSendMessage]); @@ -402,7 +424,7 @@ function getLastUsedChainIds(): string[] | null { export async function handleNebulaPrompt(params: { abortController: AbortController; - message: string; + message: NebulaUserMessage; sessionId: string; authToken: string; setMessages: React.Dispatch>; @@ -654,7 +676,15 @@ function parseHistoryToMessages(history: NebulaSessionHistoryMessage[]) { case "user": { messages.push({ - text: message.content, + content: + typeof message.content === "string" + ? [ + { + type: "text", + text: message.content, + }, + ] + : message.content, type: message.role, }); break; diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx index 3559aebc24e..cc06e959f65 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.stories.tsx @@ -33,7 +33,12 @@ export const UserPresenceError: Story = { args: { messages: [ { - text: randomLorem(10), + content: [ + { + type: "text", + text: randomLorem(10), + }, + ], type: "user", }, { @@ -205,8 +210,13 @@ export const Markdown: Story = { request_id: undefined, }, { - text: responseWithCodeMarkdown, type: "user", + content: [ + { + type: "text", + text: responseWithCodeMarkdown, + }, + ], }, ], }, diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx index c00f760e28d..1565bc4f233 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx @@ -5,6 +5,7 @@ import { AlertCircleIcon } from "lucide-react"; import { useEffect, useRef } from "react"; import type { ThirdwebClient } from "thirdweb"; import type { NebulaSwapData } from "../api/chat"; +import type { NebulaUserMessage, NebulaUserMessageContent } from "../api/types"; import { NebulaIcon } from "../icons/NebulaIcon"; import { ExecuteTransactionCard } from "./ExecuteTransactionCard"; import { MessageActions } from "./MessageActions"; @@ -20,9 +21,13 @@ export type NebulaTxData = { }; export type ChatMessage = + | { + type: "user"; + content: NebulaUserMessageContent; + } | { text: string; - type: "user" | "error"; + type: "error"; } | { texts: string[]; @@ -66,7 +71,7 @@ export function Chats(props: { setEnableAutoScroll: (enable: boolean) => void; enableAutoScroll: boolean; useSmallText?: boolean; - sendMessage: (message: string) => void; + sendMessage: (message: NebulaUserMessage) => void; }) { const { messages, setEnableAutoScroll, enableAutoScroll } = props; const scrollAnchorRef = useRef(null); @@ -122,6 +127,15 @@ export function Chats(props: { {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 (
void; + sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; authToken: string; sessionId: string | undefined; }) { const { message } = props; + if (props.message.type === "user") { return ( -
-
- -
+
+ {props.message.content.map((msg) => { + if (msg.type === "text") { + return ( +
+
+ +
+
+ ); + } + + if (msg.type === "image") { + return ( + + ); + } + + if (msg.type === "transaction") { + return null; + } + + return null; + })}
); } @@ -237,7 +277,7 @@ function RenderResponse(props: { message: ChatMessage; isMessagePending: boolean; client: ThirdwebClient; - sendMessage: (message: string) => void; + sendMessage: (message: NebulaUserMessage) => void; nextMessage: ChatMessage | undefined; sessionId: string | undefined; authToken: string; @@ -266,6 +306,7 @@ function RenderResponse(props: { case "image": { return ( ); @@ -312,7 +362,16 @@ function RenderResponse(props: { return; } - sendMessage(getTransactionSettledPrompt(txHash)); + sendMessage({ + role: "user", + content: [ + { + type: "transaction", + transaction_hash: txHash, + chain_id: message.data.transaction.chainId, + }, + ], + }); }} /> ); @@ -329,13 +388,6 @@ function RenderResponse(props: { return null; } -function getTransactionSettledPrompt(txHash: string) { - return `\ -I've executed the following transaction successfully with hash: ${txHash}. - -If our conversation calls for it, continue on to the next transaction or suggest next steps`; -} - function StyledMarkdownRenderer(props: { text: string; isMessagePending: boolean; diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx index 1e351715d0a..2d841b5ee17 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx @@ -4,13 +4,14 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ArrowUpRightIcon } from "lucide-react"; import type { NebulaContext } from "../api/chat"; +import type { NebulaUserMessage } from "../api/types"; import { examplePrompts } from "../data/examplePrompts"; import { NebulaIcon } from "../icons/NebulaIcon"; import { nebulaAppThirdwebClient } from "../utils/nebulaThirdwebClient"; import { ChatBar, type WalletMeta } from "./ChatBar"; export function EmptyStateChatPageContent(props: { - sendMessage: (message: string) => void; + sendMessage: (message: NebulaUserMessage) => void; prefillMessage: string | undefined; context: NebulaContext | undefined; setContext: (context: NebulaContext | undefined) => void; @@ -61,7 +62,12 @@ export function EmptyStateChatPageContent(props: { props.sendMessage(prompt.message)} + onClick={() => + props.sendMessage({ + role: "user", + content: [{ type: "text", text: prompt.message }], + }) + } /> ); })} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/FloatingChat/FloatingChatContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/FloatingChat/FloatingChatContent.tsx index fbfecb7fbb1..6b3a5cc8eb7 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/FloatingChat/FloatingChatContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/FloatingChat/FloatingChatContent.tsx @@ -11,6 +11,7 @@ import { } from "thirdweb/react"; import type { NebulaContext } from "../../api/chat"; import { createSession } from "../../api/session"; +import type { NebulaUserMessage } from "../../api/types"; import type { ExamplePrompt } from "../../data/examplePrompts"; import { NebulaIcon } from "../../icons/NebulaIcon"; import { ChatBar } from "../ChatBar"; @@ -99,30 +100,29 @@ function FloatingChatContentLoggedIn(props: { }, [props.authToken, contextFilters]); const handleSendMessage = useCallback( - async (userMessage: string) => { + async (userMessage: NebulaUserMessage) => { const abortController = new AbortController(); setUserHasSubmittedMessage(true); setIsChatStreaming(true); setEnableAutoScroll(true); + const textMessage = userMessage.content.find((x) => x.type === "text"); + trackEvent({ category: "floating_nebula", action: "send", label: "message", - message: userMessage, + message: textMessage?.text, page: props.pageType, sessionId: sessionId, }); - // if this is first message, set the message prefix - const messageToSend = - props.nebulaParams?.messagePrefix && !userHasSubmittedMessage - ? `${props.nebulaParams.messagePrefix}\n\n${userMessage}` - : userMessage; - setMessages((prev) => [ ...prev, - { text: userMessage, type: "user" }, + { + type: "user", + content: userMessage.content, + }, // instant loading indicator feedback to user { type: "presence", @@ -130,6 +130,24 @@ function FloatingChatContentLoggedIn(props: { }, ]); + const messagePrefix = props.nebulaParams?.messagePrefix; + + // if this is first message, set the message prefix + // deep clone `userMessage` to avoid mutating the original message, its a pretty small object so JSON.parse is fine + const messageToSend = JSON.parse( + JSON.stringify(userMessage), + ) as NebulaUserMessage; + + // if this is first message, set the message prefix + if (messagePrefix && !userHasSubmittedMessage) { + const textMessage = messageToSend.content.find( + (x) => x.type === "text", + ); + if (textMessage) { + textMessage.text = `${messagePrefix}\n\n${textMessage.text}`; + } + } + try { // Ensure we have a session ID let currentSessionId = sessionId; @@ -223,7 +241,7 @@ function FloatingChatContentLoggedIn(props: { } }} isChatStreaming={isChatStreaming} - prefillMessage="" + prefillMessage={undefined} sendMessage={handleSendMessage} className="rounded-none border-x-0 border-b-0" /> @@ -268,7 +286,7 @@ function LoggedOutStateChatContent() { } function EmptyStateChatPageContent(props: { - sendMessage: (message: string) => void; + sendMessage: (message: NebulaUserMessage) => void; examplePrompts: ExamplePrompt[]; }) { return ( @@ -293,7 +311,17 @@ function EmptyStateChatPageContent(props: { props.sendMessage(prompt.message)} + onClick={() => + props.sendMessage({ + role: "user", + content: [ + { + type: "text", + text: prompt.message, + }, + ], + }) + } /> ); })} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx index b07d5686966..dd2e4fe6adf 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx @@ -7,15 +7,24 @@ import { ArrowDownToLineIcon } from "lucide-react"; import type { ThirdwebClient } from "thirdweb"; import { MessageActions } from "./MessageActions"; -export function NebulaImage(props: { - url: string; - width: number; - height: number; - client: ThirdwebClient; - requestId: string; - sessionId: string | undefined; - authToken: string; -}) { +export function NebulaImage( + props: + | { + type: "response"; + url: string; + width: number; + height: number; + client: ThirdwebClient; + requestId: string; + sessionId: string | undefined; + authToken: string; + } + | { + type: "submitted"; + url: string; + client: ThirdwebClient; + }, +) { const src = resolveSchemeWithErrorHandler({ uri: props.url, client: props.client, @@ -32,8 +41,8 @@ export function NebulaImage(props: { return (
} @@ -53,7 +62,7 @@ export function NebulaImage(props: { )} - {props.sessionId && ( + {props.type === "response" && props.sessionId && (
x.toString()), @@ -104,7 +104,10 @@ export function NebulaLoggedOutStatePage(props: { connectedWallets={[]} setActiveWallet={() => {}} sendMessage={(msg) => { - setMessage(msg); + const textMessage = msg.content.find((x) => x.type === "text"); + if (textMessage) { + setMessage(textMessage.text); + } setShowPage("connect"); }} />