diff --git a/apps/dashboard/src/@/components/ui/image-upload-button.tsx b/apps/dashboard/src/@/components/ui/image-upload-button.tsx new file mode 100644 index 00000000000..d8dada3e7ec --- /dev/null +++ b/apps/dashboard/src/@/components/ui/image-upload-button.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import type React from "react"; +import { useRef } from "react"; + +interface ImageUploadProps { + value: File | undefined; + onChange?: (files: File[]) => void; + children?: React.ReactNode; + variant?: React.ComponentProps["variant"]; + className?: string; + multiple?: boolean; +} + +export function ImageUploadButton(props: ImageUploadProps) { + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + props.onChange?.(files); + }; + + return ( +
+ + +
+ ); +} 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 9a8515d7242..e59bb73c0e2 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/api/types.ts +++ b/apps/dashboard/src/app/nebula-app/(app)/api/types.ts @@ -8,6 +8,10 @@ type NebulaUserMessageContentItem = type: "image"; image_url: string; } + | { + type: "image"; + b64: string; + } | { type: "text"; text: 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 98b8bdd82d5..b1af35b7a3a 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatBar.tsx @@ -1,9 +1,12 @@ "use client"; +import { Img } from "@/components/blocks/Img"; import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors"; +import { DynamicHeight } from "@/components/ui/DynamicHeight"; import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { ImageUploadButton } from "@/components/ui/image-upload-button"; import { Popover, PopoverContent, @@ -11,7 +14,9 @@ import { } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { AutoResizeTextarea } from "@/components/ui/textarea"; +import { ToolTipLabel } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; import { ChainIconClient } from "components/icons/ChainIcon"; import { useAllChainsData } from "hooks/chains/allChains"; import { @@ -20,8 +25,11 @@ import { ChevronDownIcon, CircleStopIcon, CopyIcon, + PaperclipIcon, + XIcon, } from "lucide-react"; import { useState } from "react"; +import { toast } from "sonner"; import type { ThirdwebClient } from "thirdweb"; import { AccountAvatar, @@ -41,6 +49,8 @@ export type WalletMeta = { address: string; }; +const maxAllowedImagesPerMessage = 4; + export function ChatBar(props: { sendMessage: (message: NebulaUserMessage) => void; isChatStreaming: boolean; @@ -54,164 +64,346 @@ export function ChatBar(props: { connectedWallets: WalletMeta[]; setActiveWallet: (wallet: WalletMeta) => void; isConnectingWallet: boolean; - // TODO - add this option later - // showImageUploader: boolean; + allowImageUpload: boolean; + onLoginClick: undefined | (() => void); }) { 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 }> + >([]); function handleSubmit(message: string) { - setMessage(""); - props.sendMessage({ + const userMessage: NebulaUserMessage = { role: "user", - // TODO - add image here later content: [{ type: "text", text: message }], - }); + }; + if (images.length > 0) { + for (const image of images) { + if (image.b64) { + userMessage.content.push({ type: "image", b64: image.b64 }); + } + } + } + props.sendMessage(userMessage); + setMessage(""); + setImages([]); + } + + const uploadImageMutation = useMutation({ + mutationFn: async (image: File) => { + return toBase64(image); + }, + }); + + async function handleImageUpload(images: File[]) { + try { + const urls = await Promise.all( + images.map(async (image) => { + const b64 = await uploadImageMutation.mutateAsync(image); + return { file: image, b64: b64 }; + }), + ); + + setImages((prev) => [...prev, ...urls]); + } catch (e) { + console.error(e); + toast.error("Failed to upload image", { + position: "top-right", + }); + } } return ( -
-
- 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); - } - }} - className="min-h-[60px] resize-none border-none bg-transparent pt-2 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0" - disabled={props.isChatStreaming} - /> -
+ +
+ {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); + } + }} + className="min-h-[60px] resize-none border-none bg-transparent pt-2 leading-relaxed focus-visible:ring-0 focus-visible:ring-offset-0" + disabled={props.isChatStreaming} + /> +
+ +
+ {/* left */} +
+ {props.showContextSelector && ( +
+ {props.connectedWallets.length > 1 && + !props.isConnectingWallet && ( + { + props.setActiveWallet(walletMeta); + props.setContext({ + walletAddress: walletMeta.address, + chainIds: props.context?.chainIds || [], + networks: props.context?.networks || null, + }); + }} + /> + )} + + {props.isConnectingWallet && ( + + + Connecting Wallet + + )} -
- {/* left */} -
- {props.showContextSelector && ( -
- {props.connectedWallets.length > 1 && - !props.isConnectingWallet && ( - { - props.setActiveWallet(walletMeta); + hideTestnets + disableChainId + selectedChainIds={selectedChainIds} + popoverContentClassName="!w-[calc(100vw-80px)] lg:!w-[320px]" + align="start" + side="top" + showSelectedValuesInModal={true} + customTrigger={ + + } + onChange={(values) => { props.setContext({ - walletAddress: walletMeta.address, - chainIds: props.context?.chainIds || [], + walletAddress: props.context?.walletAddress || null, + chainIds: values.map((x) => x.toString()), networks: props.context?.networks || null, }); }} + 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 + ]} /> - )} - - {props.isConnectingWallet && ( - - - Connecting Wallet - +
)} +
- - {selectedChainIds.length > 0 && firstChainId && ( - - )} + {/* right */} +
+ {props.allowImageUpload ? ( + { + 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; + } - {selectedChainIds.length === 0 && ( - + + + + + ) : props.onLoginClick ? ( + + + + + +
+

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

+ - } - onChange={(values) => { - props.setContext({ - walletAddress: props.context?.walletAddress || null, - chainIds: values.map((x) => x.toString()), - networks: props.context?.networks || null, - }); - }} - 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 - ]} - /> + Sign in + +
+
+
+ ) : null} + + {/* Send / Stop */} + {props.isChatStreaming ? ( + + ) : ( + + )}
- )} +
+
+ + ); +} - {/* Send / Stop */} - {props.isChatStreaming ? ( - - ) : ( - - )} -
+ + +
+ } + /> +
+

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

+

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

+
+ + + + +
+ ); + })}
); } 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 4de667f4d9a..bdf6a718e89 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/ChatPageContent.tsx @@ -304,6 +304,7 @@ export function ChatPageContent(props: { {showEmptyState ? (
) : ( @@ -348,6 +350,8 @@ export function ChatPageContent(props: { setContextFilters(v); handleUpdateContextFilters(v); }} + allowImageUpload={true} + onLoginClick={undefined} /> diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/Chatbar.stories.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/Chatbar.stories.tsx index eed53ac15c0..582aa4d4d3a 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chatbar.stories.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chatbar.stories.tsx @@ -27,7 +27,7 @@ const userWalletAddress = "0x2d7B4e58bb163462cba2e705090a4EC56A958F2a"; function Story() { return ( -
+
+ + ( props.context, @@ -159,6 +169,7 @@ function Variant(props: { isConnectingWallet={props.isConnectingWallet || false} client={storybookThirdwebClient} abortChatStream={() => {}} + onLoginClick={undefined} isChatStreaming={props.isStreaming} sendMessage={() => {}} prefillMessage={props.prefillMessage} @@ -166,6 +177,9 @@ function Variant(props: { setContext={setContext} showContextSelector={props.showContextSelector} connectedWallets={props.connectedWallets} + allowImageUpload={ + props.allowImageUpload === undefined ? true : props.allowImageUpload + } setActiveWallet={(wallet) => { setContext({ chainIds: context?.chainIds || [], 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 1565bc4f233..34fcc5c6b5c 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/Chats.tsx @@ -179,10 +179,11 @@ function RenderMessage(props: { if (props.message.type === "user") { return (
- {props.message.content.map((msg) => { + {props.message.content.map((msg, index) => { if (msg.type === "text") { return ( -
+ // biome-ignore lint/suspicious/noArrayIndexKey: +
+ // biome-ignore lint/suspicious/noArrayIndexKey: +
+ +
); } diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.stories.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.stories.tsx index f5564070739..e7ece368e87 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.stories.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.stories.tsx @@ -34,6 +34,7 @@ function Story(props: {
{}} @@ -42,6 +43,7 @@ function Story(props: { setContext={() => {}} connectedWallets={[]} setActiveWallet={() => {}} + allowImageUpload={true} />
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 2d841b5ee17..011c803c80f 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/EmptyStateChatPageContent.tsx @@ -19,6 +19,8 @@ export function EmptyStateChatPageContent(props: { setActiveWallet: (wallet: WalletMeta) => void; isConnectingWallet: boolean; showAurora: boolean; + allowImageUpload: boolean; + onLoginClick: undefined | (() => void); }) { return (
@@ -41,6 +43,7 @@ export function EmptyStateChatPageContent(props: {
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 6b3a5cc8eb7..804fb40f52b 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 @@ -215,6 +215,7 @@ function FloatingChatContentLoggedIn(props: { /> )}
); diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx index 65d0e2ffd0a..148b3f439ec 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/MessageActions.tsx @@ -18,6 +18,7 @@ export function MessageActions(props: { sessionId: string; messageText: string | undefined; className?: string; + buttonClassName?: string; }) { const [isCopied, setIsCopied] = useState(false); function sendRating(rating: "good" | "bad") { @@ -66,7 +67,7 @@ export function MessageActions(props: { +
+ + + } + /> + + + + Image + } + /> + + + +
+ +
{props.type === "response" && props.sessionId && (
@@ -69,6 +96,7 @@ export function NebulaImage( requestId={props.requestId} sessionId={props.sessionId} messageText={undefined} + buttonClassName="bg-background/50 border-none" />
)} diff --git a/apps/dashboard/src/app/nebula-app/login/NebulaLoginPage.tsx b/apps/dashboard/src/app/nebula-app/login/NebulaLoginPage.tsx index e1b0784e1df..40d324d707b 100644 --- a/apps/dashboard/src/app/nebula-app/login/NebulaLoginPage.tsx +++ b/apps/dashboard/src/app/nebula-app/login/NebulaLoginPage.tsx @@ -89,7 +89,11 @@ export function NebulaLoggedOutStatePage(props: {
{ + setShowPage("connect"); + }} prefillMessage={props.params.q} context={{ walletAddress: null,