diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx index 8fab0e3fd72..bb294ed6793 100644 --- a/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx +++ b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChatContent.tsx @@ -8,12 +8,11 @@ import { useCallback, useState } from "react"; import type { ThirdwebClient } from "thirdweb"; import { useActiveWalletConnectionStatus } from "thirdweb/react"; import type { NebulaContext } from "../../api/chat"; -import type { NebulaUserMessage } from "../../api/types"; import type { ExamplePrompt } from "../../data/examplePrompts"; import { NebulaIcon } from "../../icons/NebulaIcon"; import { ChatBar } from "../ChatBar"; -import { Chats } from "../Chats"; -import type { ChatMessage } from "../Chats"; +import { type CustomChatMessage, CustomChats } from "./CustomChats"; +import type { UserMessage, UserMessageContent } from "./CustomChats"; export default function CustomChatContent(props: { authToken: string | undefined; @@ -49,7 +48,7 @@ function CustomChatContentLoggedIn(props: { networks: NebulaContext["networks"]; }) { const [userHasSubmittedMessage, setUserHasSubmittedMessage] = useState(false); - const [messages, setMessages] = useState>([]); + const [messages, setMessages] = useState>([]); // sessionId is initially undefined, will be set to conversationId from API after first response const [sessionId, setSessionId] = useState(undefined); const [chatAbortController, setChatAbortController] = useState< @@ -61,13 +60,15 @@ function CustomChatContentLoggedIn(props: { const connectionStatus = useActiveWalletConnectionStatus(); const handleSendMessage = useCallback( - async (userMessage: NebulaUserMessage) => { + async (userMessage: UserMessage) => { const abortController = new AbortController(); setUserHasSubmittedMessage(true); setIsChatStreaming(true); setEnableAutoScroll(true); - const textMessage = userMessage.content.find((x) => x.type === "text"); + const textMessage = userMessage.content.find( + (x: UserMessageContent) => x.type === "text", + ); trackEvent({ category: "siwa", @@ -80,7 +81,7 @@ function CustomChatContentLoggedIn(props: { ...prev, { type: "user", - content: userMessage.content, + content: userMessage.content as UserMessageContent[], }, // instant loading indicator feedback to user { @@ -93,7 +94,7 @@ function CustomChatContentLoggedIn(props: { // 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; + ) as UserMessage; try { setChatAbortController(abortController); @@ -149,6 +150,70 @@ function CustomChatContentLoggedIn(props: { [props.authToken, props.clientId, props.teamId, sessionId, trackEvent], ); + const handleFeedback = useCallback( + async (messageIndex: number, feedback: 1 | -1) => { + if (!sessionId) { + console.error("Cannot submit feedback: missing session ID"); + return; + } + + // Validate message exists and is of correct type + const message = messages[messageIndex]; + if (!message || message.type !== "assistant") { + console.error("Invalid message for feedback:", messageIndex); + return; + } + + // Prevent duplicate feedback + if (message.feedback) { + console.warn("Feedback already submitted for this message"); + return; + } + + try { + trackEvent({ + category: "siwa", + action: "submit-feedback", + rating: feedback === 1 ? "good" : "bad", + sessionId, + teamId: props.teamId, + }); + + const apiUrl = process.env.NEXT_PUBLIC_SIWA_URL; + const response = await fetch(`${apiUrl}/v1/chat/feedback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${props.authToken}`, + ...(props.teamId ? { "x-team-id": props.teamId } : {}), + }, + body: JSON.stringify({ + conversationId: sessionId, + feedbackRating: feedback, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Update the message with feedback + setMessages((prev) => + prev.map((msg, index) => + index === messageIndex && msg.type === "assistant" + ? { ...msg, feedback } + : msg, + ), + ); + } catch (error) { + console.error("Failed to send feedback:", error); + // Optionally show user-facing error notification + // Consider implementing retry logic here + } + }, + [sessionId, props.authToken, props.teamId, trackEvent, messages], + ); + const showEmptyState = !userHasSubmittedMessage && messages.length === 0; return (
@@ -158,7 +223,7 @@ function CustomChatContentLoggedIn(props: { examplePrompts={props.examplePrompts} /> ) : ( - )} { + const userMessage: UserMessage = { + type: "user", + content: siwaUserMessage.content + .filter((c) => c.type === "text") + .map((c) => ({ type: "text", text: c.text })), + }; + handleSendMessage(userMessage); + }} className="rounded-none border-x-0 border-b-0" allowImageUpload={false} /> @@ -237,7 +311,7 @@ function LoggedOutStateChatContent() { } function EmptyStateChatPageContent(props: { - sendMessage: (message: NebulaUserMessage) => void; + sendMessage: (message: UserMessage) => void; examplePrompts: { title: string; message: string }[]; }) { return ( @@ -264,7 +338,7 @@ function EmptyStateChatPageContent(props: { size="sm" onClick={() => props.sendMessage({ - role: "user", + type: "user", content: [ { type: "text", diff --git a/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChats.tsx b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChats.tsx new file mode 100644 index 00000000000..172c03d2d30 --- /dev/null +++ b/apps/dashboard/src/app/nebula-app/(app)/components/CustomChat/CustomChats.tsx @@ -0,0 +1,345 @@ +import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow"; +import { cn } from "@/lib/utils"; +import { MarkdownRenderer } from "components/contract-components/published-contract/markdown-renderer"; +import { + AlertCircleIcon, + MessageCircleIcon, + ThumbsDownIcon, + ThumbsUpIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { ThirdwebClient } from "thirdweb"; +import { Reasoning } from "../Reasoning/Reasoning"; + +// Define local types +export type UserMessageContent = { type: "text"; text: string }; +export type UserMessage = { + type: "user"; + content: UserMessageContent[]; +}; + +export type CustomChatMessage = + | UserMessage + | { + text: string; + type: "error"; + } + | { + texts: string[]; + type: "presence"; + } + | { + request_id: string | undefined; + text: string; + type: "assistant"; + feedback?: 1 | -1; + }; + +export function CustomChats(props: { + messages: Array; + isChatStreaming: boolean; + authToken: string; + sessionId: string | undefined; + className?: string; + client: ThirdwebClient; + setEnableAutoScroll: (enable: boolean) => void; + enableAutoScroll: boolean; + useSmallText?: boolean; + sendMessage: (message: UserMessage) => void; + onFeedback?: (messageIndex: number, feedback: 1 | -1) => 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; + + return ( +
+ +
+ ); + })} +
+
+
+ +
+ ); +} + +function RenderMessage(props: { + message: CustomChatMessage; + messageIndex: number; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: UserMessage) => void; + nextMessage: CustomChatMessage | undefined; + authToken: string; + sessionId: string | undefined; + onFeedback?: (messageIndex: number, feedback: 1 | -1) => void; +}) { + 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: +
+
+ +
+
+ ); + } + + return null; + })} +
+ ); + } + + return ( +
+ {/* Left Icon */} +
+
+ {(message.type === "presence" || message.type === "assistant") && ( + + )} + + {message.type === "error" && ( + + )} +
+
+ + {/* Right Message */} +
+ + + + + {/* Custom Feedback Buttons */} + {message.type === "assistant" && + !props.isMessagePending && + props.onFeedback && ( + + )} +
+
+ ); +} + +function CustomFeedbackButtons(props: { + message: CustomChatMessage & { type: "assistant" }; + messageIndex: number; + onFeedback: (messageIndex: number, feedback: 1 | -1) => void; + className?: string; +}) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleFeedback = async (feedback: 1 | -1) => { + if (isSubmitting || props.message.feedback) return; + + setIsSubmitting(true); + try { + await props.onFeedback(props.messageIndex, feedback); + } catch (_e) { + // Handle error silently + } finally { + setIsSubmitting(false); + } + }; + + // Don't show buttons if feedback already given + if (props.message.feedback) { + return null; + } + + return ( +
+ + +
+ ); +} + +function RenderResponse(props: { + message: CustomChatMessage; + isMessagePending: boolean; + client: ThirdwebClient; + sendMessage: (message: UserMessage) => void; + nextMessage: CustomChatMessage | undefined; + sessionId: string | undefined; + authToken: string; +}) { + const { message, isMessagePending } = props; + + switch (message.type) { + case "assistant": + return ( + + ); + + case "presence": + return ; + + case "error": + return ( +
+ {message.text} +
+ ); + + case "user": { + return null; + } + + default: { + // This ensures TypeScript will catch if we miss a case + const _exhaustive: never = message; + console.error("Unhandled message type:", _exhaustive); + return null; + } + } +} + +function StyledMarkdownRenderer(props: { + text: string; + isMessagePending: boolean; + type: "assistant" | "user"; +}) { + return ( + + ); +}