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 9f1c352c7a0..01ca266015e 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts +++ b/apps/dashboard/src/app/nebula-app/(app)/api/chat.ts @@ -99,6 +99,24 @@ export async function promptNebula(params: { break; } + case "image": { + const data = JSON.parse(event.data) as { + data: { + width: number; + height: number; + url: string; + }; + request_id: string; + }; + + params.handleStream({ + event: "image", + data: data.data, + request_id: data.request_id, + }); + break; + } + case "action": { const data = JSON.parse(event.data); @@ -109,6 +127,7 @@ export async function promptNebula(params: { event: "action", type: "sign_transaction", data: parsedTxData, + request_id: data.request_id, }); } catch (e) { console.error("failed to parse action data", e, { event }); @@ -122,6 +141,7 @@ export async function promptNebula(params: { event: "action", type: "sign_swap", data: swapData, + request_id: data.request_id, }); } catch (e) { console.error("failed to parse action data", e, { event }); @@ -197,11 +217,22 @@ type ChatStreamedResponse = 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"; @@ -225,6 +256,10 @@ type ChatStreamedEvent = event: "delta"; data: string; } + | { + event: "image"; + data: string; + } | { event: "action"; type: "sign_transaction" | "sign_swap"; 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 ee6a2c83432..915751d6702 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/api/types.ts +++ b/apps/dashboard/src/app/nebula-app/(app)/api/types.ts @@ -3,6 +3,12 @@ type SessionContextFilter = { wallet_address: string | null; }; +export type NebulaSessionHistoryMessage = { + role: "user" | "assistant" | "action" | "image"; + content: string; + timestamp: number; +}; + export type SessionInfo = { id: string; account_id: string; @@ -11,11 +17,7 @@ export type SessionInfo = { can_execute: boolean; created_at: string; deleted_at: string | null; - history: Array<{ - role: "user" | "assistant" | "action"; - content: string; - timestamp: number; - }> | null; + history: Array | null; updated_at: string; archived_at: string | null; title: string | null; 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 26c031a2664..1f4f36face7 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,7 @@ import { } from "thirdweb/react"; import { type NebulaContext, promptNebula } from "../api/chat"; import { createSession, updateSession } from "../api/session"; -import type { SessionInfo } from "../api/types"; +import type { NebulaSessionHistoryMessage, SessionInfo } from "../api/types"; import { examplePrompts } from "../data/examplePrompts"; import { newSessionsStore } from "../stores"; import { ChatBar, type WalletMeta } from "./ChatBar"; @@ -48,45 +48,7 @@ export function ChatPageContent(props: { const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); const [messages, setMessages] = useState>(() => { if (props.session?.history) { - const _messages: ChatMessage[] = []; - - for (const message of props.session.history) { - if (message.role === "action") { - try { - const content = JSON.parse(message.content) as { - session_id: string; - data: string; - type: "sign_transaction" | (string & {}); - }; - - if (content.type === "sign_transaction") { - const txData = JSON.parse(content.data); - _messages.push({ - type: "action", - subtype: "sign_transaction", - data: txData, - }); - } else if (content.type === "sign_swap") { - const swapData = JSON.parse(content.data); - _messages.push({ - type: "action", - subtype: "sign_swap", - data: swapData, - }); - } - } catch (e) { - console.error("error processing message", e, { message }); - } - } else { - _messages.push({ - text: message.content, - type: message.role, - request_id: undefined, - }); - } - } - - return _messages; + return parseHistoryToMessages(props.session.history); } return []; }); @@ -316,7 +278,7 @@ export function ChatPageContent(props: { />
-
+
{showEmptyState ? (
{ - const lastMessage = prev[prev.length - 1]; - - // append to previous assistant message - if (lastMessage?.type === "assistant") { + case "image": { + hasReceivedResponse = true; + setMessages((prevMessages) => { return [ - ...prev.slice(0, -1), + ...prevMessages, { - text: lastMessage.text + res.data.v, - type: "assistant", - request_id: requestIdForMessage, + type: "image", + data: res.data, + request_id: res.request_id, }, ]; - } - - // start a new assistant message - return [ - ...prev, - { - text: res.data.v, - type: "assistant", - request_id: requestIdForMessage, - }, - ]; - }); - } - - if (res.event === "presence") { - setMessages((prev) => { - const lastMessage = prev[prev.length - 1]; + }); + return; + } - // append to previous presence message - if (lastMessage?.type === "presence") { - return [ - ...prev.slice(0, -1), - { - type: "presence", - texts: [...lastMessage.texts, res.data.data], - }, - ]; + case "delta": { + // ignore empty string delta + if (!res.data.v) { + return; } - // start a new presence message - return [...prev, { texts: [res.data.data], type: "presence" }]; - }); - } + hasReceivedResponse = true; + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + + // append to previous assistant message + if (lastMessage?.type === "assistant") { + return [ + ...prev.slice(0, -1), + { + text: lastMessage.text + res.data.v, + type: "assistant", + request_id: requestIdForMessage, + }, + ]; + } - if (res.event === "action") { - hasReceivedResponse = true; - if (res.type === "sign_transaction") { - setMessages((prevMessages) => { + // start a new assistant message return [ - ...prevMessages, + ...prev, { - type: "action", - subtype: res.type, - data: res.data, + text: res.data.v, + type: "assistant", + request_id: requestIdForMessage, }, ]; }); - } else if (res.type === "sign_swap") { - setMessages((prevMessages) => { - return [ - ...prevMessages, - { - type: "action", - subtype: res.type, - data: res.data, - }, - ]; + 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), + { + type: "presence", + texts: [...lastMessage.texts, res.data.data], + }, + ]; + } + + // start a new presence message + return [...prev, { texts: [res.data.data], type: "presence" }]; }); + return; } - } - if (res.event === "context") { - setContextFilters({ - chainIds: res.data.chain_ids.map((x) => x.toString()), - walletAddress: res.data.wallet_address, - networks: res.data.networks, - }); + case "action": { + hasReceivedResponse = true; + switch (res.type) { + case "sign_transaction": { + setMessages((prevMessages) => { + return [ + ...prevMessages, + { + type: "action", + subtype: res.type, + data: res.data, + request_id: res.request_id, + }, + ]; + }); + return; + } + case "sign_swap": { + setMessages((prevMessages) => { + return [ + ...prevMessages, + { + type: "action", + subtype: res.type, + data: res.data, + request_id: res.request_id, + }, + ]; + }); + return; + } + } + return; + } + + case "context": { + setContextFilters({ + chainIds: res.data.chain_ids.map((x) => x.toString()), + walletAddress: res.data.wallet_address, + networks: res.data.networks, + }); + return; + } } }, context: contextFilters, @@ -604,3 +595,80 @@ export function handleNebulaPromptError(params: { 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({ + type: "action", + subtype: "sign_transaction", + data: txData, + request_id: content.request_id, + }); + } else if (content.type === "sign_swap") { + const swapData = JSON.parse(content.data); + messages.push({ + type: "action", + subtype: "sign_swap", + data: swapData, + request_id: content.request_id, + }); + } + } 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({ + type: "image", + data: content.data, + request_id: content.request_id, + }); + break; + } + + case "user": { + messages.push({ + text: message.content, + type: message.role, + }); + break; + } + + case "assistant": { + messages.push({ + text: message.content, + type: message.role, + request_id: undefined, + }); + } + } + } + + return messages; +} 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 632e23a16eb..3559aebc24e 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 @@ -59,6 +59,7 @@ export const SendTransaction: Story = { { type: "action", subtype: "sign_transaction", + request_id: "xxxxx", data: { chainId: 1, to: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37", 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 cadaefa4c30..c00f760e28d 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx @@ -1,23 +1,14 @@ import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; -import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { useMutation } from "@tanstack/react-query"; import { MarkdownRenderer } from "components/contract-components/published-contract/markdown-renderer"; -import { - AlertCircleIcon, - CheckIcon, - CopyIcon, - ThumbsDownIcon, - ThumbsUpIcon, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; +import { AlertCircleIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; import type { ThirdwebClient } from "thirdweb"; import type { NebulaSwapData } from "../api/chat"; -import { submitFeedback } from "../api/feedback"; import { NebulaIcon } from "../icons/NebulaIcon"; import { ExecuteTransactionCard } from "./ExecuteTransactionCard"; +import { MessageActions } from "./MessageActions"; +import { NebulaImage } from "./NebulaImage"; import { Reasoning } from "./Reasoning/Reasoning"; import { ApproveTransactionCard, SwapTransactionCard } from "./Swap/SwapCards"; @@ -46,12 +37,23 @@ export type ChatMessage = | { 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: { @@ -129,69 +131,15 @@ export function Chats(props: { // biome-ignore lint/suspicious/noArrayIndexKey: index is the unique key key={index} > - {message.type === "user" ? ( -
-
- -
-
- ) : ( -
- {/* Left Icon */} -
-
- {message.type === "presence" && ( - - )} - - {message.type === "assistant" && ( - - )} - - {message.type === "error" && ( - - )} -
-
- - {/* Right Message */} -
- - - - - {message.type === "assistant" && - !isMessagePending && - props.sessionId && - message.request_id && ( - - )} -
-
- )} +
); })} @@ -209,6 +157,90 @@ function RenderMessage(props: { client: ThirdwebClient; sendMessage: (message: string) => void; nextMessage: ChatMessage | undefined; + authToken: string; + sessionId: string | undefined; +}) { + const { message } = props; + if (props.message.type === "user") { + return ( +
+
+ +
+
+ ); + } + + 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: string) => void; + nextMessage: ChatMessage | undefined; + sessionId: string | undefined; + authToken: string; }) { const { message, isMessagePending, client, sendMessage, nextMessage } = props; @@ -231,6 +263,19 @@ function RenderMessage(props: { {message.text}
); + case "image": { + return ( + + ); + } case "action": { if (message.subtype === "sign_transaction") { @@ -272,6 +317,12 @@ function RenderMessage(props: { /> ); } + + return null; + } + + case "user": { + return null; } } @@ -285,103 +336,6 @@ 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 MessageActions(props: { - authToken: string; - requestId: string; - sessionId: string; - messageText: string; - className?: string; -}) { - const [isCopied, setIsCopied] = useState(false); - function sendRating(rating: "good" | "bad") { - return submitFeedback({ - authToken: props.authToken, - rating, - requestId: props.requestId, - sessionId: props.sessionId, - }); - } - const sendPositiveRating = useMutation({ - mutationFn: () => sendRating("good"), - onSuccess() { - toast.info("Thanks for the feedback!"); - }, - onError() { - toast.error("Failed to send feedback"); - }, - }); - - const sendBadRating = useMutation({ - mutationFn: () => sendRating("bad"), - onSuccess() { - toast.info("Thanks for the feedback!", { - position: "top-right", - }); - }, - onError() { - toast.error("Failed to send feedback", { - position: "top-right", - }); - }, - }); - - return ( -
- - - - - -
- ); -} - function StyledMarkdownRenderer(props: { text: string; isMessagePending: boolean; diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx new file mode 100644 index 00000000000..65d0e2ffd0a --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx @@ -0,0 +1,118 @@ +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { + CheckIcon, + CopyIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { submitFeedback } from "../api/feedback"; + +export function MessageActions(props: { + authToken: string; + requestId: string; + sessionId: string; + messageText: string | undefined; + className?: string; +}) { + const [isCopied, setIsCopied] = useState(false); + function sendRating(rating: "good" | "bad") { + return submitFeedback({ + authToken: props.authToken, + rating, + requestId: props.requestId, + sessionId: props.sessionId, + }); + } + const sendPositiveRating = useMutation({ + mutationFn: () => sendRating("good"), + onSuccess() { + toast.info("Thanks for the feedback!", { + position: "top-right", + }); + }, + onError() { + toast.error("Failed to send feedback", { + position: "top-right", + }); + }, + }); + + const sendBadRating = useMutation({ + mutationFn: () => sendRating("bad"), + onSuccess() { + toast.info("Thanks for the feedback!", { + position: "top-right", + }); + }, + onError() { + toast.error("Failed to send feedback", { + position: "top-right", + }); + }, + }); + + const { messageText } = props; + + return ( +
+ {messageText && ( + + )} + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx new file mode 100644 index 00000000000..b07d5686966 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/(app)/components/NebulaImage.tsx @@ -0,0 +1,85 @@ +import { Img } from "@/components/blocks/Img"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; +import { Button } from "@/components/ui/button"; +import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; +import { useMutation } from "@tanstack/react-query"; +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; +}) { + const src = resolveSchemeWithErrorHandler({ + uri: props.url, + client: props.client, + }); + + const downloadMutation = useMutation({ + mutationFn: () => downloadImage(src || ""), + }); + + if (!src) { + return null; + } + + return ( +
+ } + /> + + + {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); + } +}