From 62b7b6bc306079c7e8742758243a853c1e4074c2 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:06:33 -0500 Subject: [PATCH 001/120] chore(env): setup env vars for chatbot --- .env.example | 4 ++++ conf/inject.template.js | 2 ++ public/js/injectEnv.js | 2 ++ src/types/AppEnv.d.ts | 11 +++++++++++ 4 files changed, 19 insertions(+) diff --git a/.env.example b/.env.example index 4fae26378..fc36927b8 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,7 @@ VITE_GA_TRACKING_ID="" # Optional - Extend test debug print limit # DEBUG_PRINT_LIMIT=200000 + +# Optional - ChatBot Config +VITE_CHATBOT_ENABLED=true +VITE_KNOWLEDGE_BASE_URL=""; \ No newline at end of file diff --git a/conf/inject.template.js b/conf/inject.template.js index 0e680e3e1..b3c8408b1 100644 --- a/conf/inject.template.js +++ b/conf/inject.template.js @@ -14,4 +14,6 @@ window.injectedEnv = { VITE_UPLOADER_CLI_MAC_X64: "${REACT_APP_UPLOADER_CLI_MAC_X64}", VITE_UPLOADER_CLI_MAC_ARM: "${REACT_APP_UPLOADER_CLI_MAC_ARM}", VITE_HIDDEN_MODELS: "${HIDDEN_MODELS}", + VITE_CHATBOT_ENABLED: "${REACT_APP_CHATBOT_ENABLED}", + VITE_KNOWLEDGE_BASE_URL: "${REACT_APP_KNOWLEDGE_BASE_URL}", }; diff --git a/public/js/injectEnv.js b/public/js/injectEnv.js index f1b0c5a94..e218ea2e9 100644 --- a/public/js/injectEnv.js +++ b/public/js/injectEnv.js @@ -12,4 +12,6 @@ window.injectedEnv = { VITE_FE_VERSION: "", VITE_BACKEND_API: "", VITE_HIDDEN_MODELS: "", + VITE_CHATBOT_ENABLED: null, + VITE_KNOWLEDGE_BASE_URL: "", }; diff --git a/src/types/AppEnv.d.ts b/src/types/AppEnv.d.ts index cda609447..d31620c3d 100644 --- a/src/types/AppEnv.d.ts +++ b/src/types/AppEnv.d.ts @@ -73,6 +73,17 @@ type AppEnv = { * @since 3.1.0 */ VITE_HIDDEN_MODELS: string; + /** + * Knowledge Base URL + */ + VITE_KNOWLEDGE_BASE_URL: string; + /** + * Enable or disable the chatbot feature + * + * @default "true" + * @note Set to "false" to disable the chatbot + */ + VITE_CHATBOT_ENABLED: string; /** * The deployment environment the app is running in */ From 9174a1bcdce293e49d2bc2d902bc282b1620072f Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:11:50 -0500 Subject: [PATCH 002/120] chore(types): add chatbot related types --- src/types/ChatBot.d.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/types/ChatBot.d.ts diff --git a/src/types/ChatBot.d.ts b/src/types/ChatBot.d.ts new file mode 100644 index 000000000..6336a44a7 --- /dev/null +++ b/src/types/ChatBot.d.ts @@ -0,0 +1,14 @@ +type ChatSender = "user" | "bot"; + +type ChatStatus = "idle" | "bot_typing"; + +type ChatMessageVariant = "default" | "error" | "info"; + +type ChatMessage = { + id: string; + text: string; + sender: ChatSender; + timestamp: Date; + senderName: string; + variant?: ChatMessageVariant; +}; From 7a8fae8d82058ad88d7f469d53200c4837a29e61 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:13:41 -0500 Subject: [PATCH 003/120] init: chat config --- src/components/ChatBot/chatConfig.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/components/ChatBot/chatConfig.ts diff --git a/src/components/ChatBot/chatConfig.ts b/src/components/ChatBot/chatConfig.ts new file mode 100644 index 000000000..474bf676e --- /dev/null +++ b/src/components/ChatBot/chatConfig.ts @@ -0,0 +1,36 @@ +/** + * Configuration settings for the ChatBot component. + */ +const chatConfig = { + /** + * The name of the support bot. + */ + supportBotName: "Support Bot", + /** + * The display name of the user. + */ + userDisplayName: "You", + /** + * The initial message sent by the support bot when the chat starts a new conversation. + */ + initialMessage: "Hi there! 👋 How can I help you today?", + /** + * The height configuration for the chat drawer. + */ + height: { + /** + * The height of the chat drawer when it is collapsed. + */ + collapsed: 475, + /** + * The minimum height of the chat drawer. + */ + min: 475, + /** + * The threshold in pixels at which the chat drawer will snap to its expanded height. + */ + expandedSnapThreshold: 8, + }, +}; + +export default chatConfig; From 7c03aa31c1882dad16e0191f0c80af83144a2902 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:24:10 -0500 Subject: [PATCH 004/120] chore(api): add knowledge base API call --- .../ChatBot/api/knowledgeBaseClient.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/components/ChatBot/api/knowledgeBaseClient.ts diff --git a/src/components/ChatBot/api/knowledgeBaseClient.ts b/src/components/ChatBot/api/knowledgeBaseClient.ts new file mode 100644 index 000000000..088d174a7 --- /dev/null +++ b/src/components/ChatBot/api/knowledgeBaseClient.ts @@ -0,0 +1,51 @@ +import env from "@/env"; +import { Logger } from "@/utils"; + +export type KnowledgeBaseCitation = { + title?: string; + url?: string; + snippet?: string; +}; + +export type AskKnowledgeBaseResponse = { + question: string; + answer: string; + citations?: KnowledgeBaseCitation[]; +}; + +type AskKnowledgeBaseArgs = { + question: string; + sessionId: string; + signal?: AbortSignal; + url?: string; +}; + +export const askKnowledgeBase = async ({ + question, + sessionId, + signal, + url = env.VITE_KNOWLEDGE_BASE_URL, +}: AskKnowledgeBaseArgs): Promise => { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal, + body: JSON.stringify({ question, sessionId }), + }); + + if (!res.ok) { + throw new Error(`KnowledgeBase HTTP ${res.status}`); + } + + const data = (await res.json()) as AskKnowledgeBaseResponse; + + if (!data?.answer) { + Logger.error( + "knowledgeBaseClient.ts: The knowledge base response is missing an 'answer'.", + data + ); + throw new Error("Oops! Unable to retrieve a response."); + } + + return data; +}; From d189ae11a4da6dadc2491100c0eb12a4ef29e513 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:31:13 -0500 Subject: [PATCH 005/120] init: utils used for chatbot --- src/components/ChatBot/utils/chatUtils.ts | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/components/ChatBot/utils/chatUtils.ts diff --git a/src/components/ChatBot/utils/chatUtils.ts b/src/components/ChatBot/utils/chatUtils.ts new file mode 100644 index 000000000..d81d9f80a --- /dev/null +++ b/src/components/ChatBot/utils/chatUtils.ts @@ -0,0 +1,77 @@ +import { clamp } from "lodash"; +import { v4 } from "uuid"; + +import chatConfig from "../chatConfig"; + +/** + * Gets the current viewport height, or returns a fallback value if window is unavailable. + * + * @param fallback - Default height if window is unavailable + * @return Current viewport height in pixels + */ +export const getViewportHeightPx = (fallback: number): number => { + if (typeof window === "undefined") { + return fallback; + } + + return window.innerHeight; +}; + +/** + * Calculates the next drawer height based on mouse position, clamped to min/max bounds. + * + * @param args - Drawer element and mouse Y position + * @return Clamped drawer height and current viewport height + */ +export const computeNextHeightPx = (args: { + drawerElement: HTMLDivElement; + clientY: number; +}): { heightPx: number; viewportHeightPx: number } => { + const rect = args.drawerElement.getBoundingClientRect(); + const rawHeightPx = rect.bottom - args.clientY; + + const viewportHeightPx = getViewportHeightPx(chatConfig.height.collapsed); + const heightPx = clamp(rawHeightPx, chatConfig.height.min, viewportHeightPx); + + return { heightPx, viewportHeightPx }; +}; + +/** + * Determines if an error is an AbortError. + * + * @param error - Error object to check + * @return True if error is an AbortError + */ +export const isAbortError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + return error.name === "AbortError"; +}; + +/** + * Generates a unique identifier with the given prefix. + * + * @param prefix - ID prefix + * @return Unique ID with prefix and UUID + */ +export const createId = (prefix: string): string => `${prefix}${v4()}`; + +/** + * Creates a chat message object with provided content and metadata. + * + * @param args - Message text, sender, name, and optional variant + * @return New chat message object + */ +export const createChatMessage = (args: { + text: string; + sender: ChatSender; + senderName: string; + variant?: ChatMessageVariant; +}): ChatMessage => ({ + id: createId("chat_msg_"), + timestamp: new Date(), + variant: args.variant ?? "default", + ...args, +}); From c536864d5d9b0da7569bb1a01149a858b1e06c63 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 14:38:46 -0500 Subject: [PATCH 006/120] init: chat input for sending messages --- src/components/ChatBot/panel/ChatComposer.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/components/ChatBot/panel/ChatComposer.tsx diff --git a/src/components/ChatBot/panel/ChatComposer.tsx b/src/components/ChatBot/panel/ChatComposer.tsx new file mode 100644 index 000000000..d0463c184 --- /dev/null +++ b/src/components/ChatBot/panel/ChatComposer.tsx @@ -0,0 +1,105 @@ +import SendIcon from "@mui/icons-material/Send"; +import { Box, IconButton, Stack, styled } from "@mui/material"; +import React, { useCallback } from "react"; + +import StyledOutlinedInput from "@/components/StyledFormComponents/StyledOutlinedInput"; + +const StyledBox = styled(Box)({ + borderTop: "1px solid rgba(0,0,0,0.12)", + padding: "12px", + backgroundColor: "#FFFFFF", +}); + +const StyledTextField = styled(StyledOutlinedInput)({ + "&.MuiOutlinedInput-root": { + borderRadius: "8px", + border: "0 !important", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + border: "1px solid #005EA2", + }, + "& .MuiInputBase-input": { + fontSize: "14px", + padding: "10px", + }, +}); + +const StyledSendButton = styled(IconButton)({ + backgroundColor: "#005EA2", + color: "#FFFFFF", + borderRadius: "8px", + width: "40px", + height: "40px", + "&:hover": { + backgroundColor: "#115293", + }, + "&.Mui-disabled": { + backgroundColor: "rgba(0,0,0,0.12)", + color: "rgba(0,0,0,0.26)", + }, +}); + +export type Props = { + /** + * The current value of the input field. + */ + value: string; + /** + * Callback fired when the input value changes. + */ + onChange: (value: string) => void; + /** + * Callback fired when the send button is clicked. + */ + onSend: () => void; + /** + * Callback fired on keyboard events in the input field. + */ + onKeyDown: React.KeyboardEventHandler; + /** + * Indicates whether the send button should be disabled. + */ + isSendDisabled: boolean; +}; + +/** + * Input field and send button for composing and sending chat messages. + */ +const ChatComposer = ({ + value, + onChange, + onSend, + onKeyDown, + isSendDisabled, +}: Props): JSX.Element => { + /** + * Handles input value changes and propagates them to the parent. + */ + const handleInputChange = useCallback( + (event: React.ChangeEvent): void => { + onChange(event.target.value); + }, + [onChange] + ); + + return ( + + + + + + + + + ); +}; + +export default React.memo(ChatComposer); From 589db01da189fa8f6503e84148ed23f35437d803 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Fri, 19 Dec 2025 15:18:08 -0500 Subject: [PATCH 007/120] init: chat panel containing messages --- src/components/ChatBot/ChatPanel.tsx | 59 ++++++++ .../ChatBot/panel/BotTypingIndicator.tsx | 104 +++++++++++++ .../ChatBot/panel/ChatMessageItem.tsx | 137 ++++++++++++++++++ src/components/ChatBot/panel/MessageList.tsx | 88 +++++++++++ 4 files changed, 388 insertions(+) create mode 100644 src/components/ChatBot/ChatPanel.tsx create mode 100644 src/components/ChatBot/panel/BotTypingIndicator.tsx create mode 100644 src/components/ChatBot/panel/ChatMessageItem.tsx create mode 100644 src/components/ChatBot/panel/MessageList.tsx diff --git a/src/components/ChatBot/ChatPanel.tsx b/src/components/ChatBot/ChatPanel.tsx new file mode 100644 index 000000000..b1e7accac --- /dev/null +++ b/src/components/ChatBot/ChatPanel.tsx @@ -0,0 +1,59 @@ +import { Stack } from "@mui/material"; +import React, { useCallback, useMemo } from "react"; + +import { useChatConversation } from "./hooks/useChatConversation"; +import ChatComposer from "./panel/ChatComposer"; +import MessageList from "./panel/MessageList"; + +/** + * Renders the main chat interface with message history and user input composer. + */ +const ChatPanel = (): JSX.Element => { + const { + greetingTimestamp, + messages, + inputValue, + isBotTyping, + setInputValue, + sendMessage, + handleKeyDown, + } = useChatConversation(); + + /** + * Determines if the send button should be disabled based on input state and bot typing status. + */ + const isSendDisabled = useMemo((): boolean => { + if (isBotTyping) { + return true; + } + + return inputValue?.trim()?.length === 0; + }, [inputValue, isBotTyping]); + + /** + * Handles input value changes and updates the state. + */ + const handleValueChange = useCallback( + (value: string): void => setInputValue(value), + [setInputValue] + ); + + return ( + + + + + ); +}; + +export default React.memo(ChatPanel); diff --git a/src/components/ChatBot/panel/BotTypingIndicator.tsx b/src/components/ChatBot/panel/BotTypingIndicator.tsx new file mode 100644 index 000000000..511dacaec --- /dev/null +++ b/src/components/ChatBot/panel/BotTypingIndicator.tsx @@ -0,0 +1,104 @@ +import { Box, Stack, Typography, styled } from "@mui/material"; +import React from "react"; + +import chatConfig from "../chatConfig"; + +const TypingSender = styled(Typography)({ + fontSize: "12px", + fontWeight: 500, + color: "rgba(0,0,0,0.7)", + paddingInline: "4px", + marginBottom: "4px", +}); + +const TypingBubble = styled(Box)({ + display: "inline-flex", + alignItems: "center", + gap: 10, + paddingInline: "14px", + paddingBlock: "10px", + minHeight: "36px", + borderRadius: "12px", + backgroundColor: "#F5F5F5", +}); + +const TypingDot = styled("span")({ + "--dot-size": "10px", + "--dot-border-width": "1px", + "--dot-border-color": "rgba(0, 94, 162, 0.28)", + "--dot-color-1": "#005EA2", + "--dot-color-2": "#2B78B3", + "--dot-color-3": "#5A99C8", + "--animation-duration": "1200ms", + "--animation-delay-step": "260ms", + + width: "var(--dot-size)", + height: "var(--dot-size)", + borderRadius: "50%", + display: "block", + boxSizing: "border-box", + backgroundColor: "transparent", + border: "var(--dot-border-width) solid var(--dot-border-color)", + animationName: "typingDotSweep", + animationDuration: "var(--animation-duration)", + animationIterationCount: "infinite", + animationTimingFunction: "ease-in-out", + + "@keyframes typingDotSweep": { + "0%": { + backgroundColor: "var(--dot-color)", + borderColor: "transparent", + }, + "24%": { + backgroundColor: "var(--dot-color)", + borderColor: "transparent", + }, + "34%": { + backgroundColor: "transparent", + borderColor: "var(--dot-border-color)", + }, + "100%": { + backgroundColor: "transparent", + borderColor: "var(--dot-border-color)", + }, + }, + + "&:nth-of-type(1)": { + ["--dot-color" as string]: "var(--dot-color-1)", + animationDelay: "0ms", + }, + "&:nth-of-type(2)": { + ["--dot-color" as string]: "var(--dot-color-2)", + animationDelay: "var(--animation-delay-step)", + }, + "&:nth-of-type(3)": { + ["--dot-color" as string]: "var(--dot-color-3)", + animationDelay: "calc(var(--animation-delay-step) * 2)", + }, +}); + +export type Props = { + /** + * The name of the bot sender to display. Defaults to the configured support bot name. + */ + senderName?: string; +}; + +/** + * Displays an animated typing indicator with the bot's name and three animated dots. + */ +const BotTypingIndicator = ({ senderName = chatConfig.supportBotName }: Props): JSX.Element => ( + + + {senderName} + + + + + + + + +); + +export default React.memo(BotTypingIndicator); diff --git a/src/components/ChatBot/panel/ChatMessageItem.tsx b/src/components/ChatBot/panel/ChatMessageItem.tsx new file mode 100644 index 000000000..bca135b7b --- /dev/null +++ b/src/components/ChatBot/panel/ChatMessageItem.tsx @@ -0,0 +1,137 @@ +import { Box, Typography, styled } from "@mui/material"; +import React, { CSSProperties } from "react"; + +const MessageRow = styled(Box)({ + display: "flex", + justifyContent: "flex-start", + marginBottom: "12px", + '&[data-is-user="true"]': { + justifyContent: "flex-end", + }, +}); + +const MessageColumn = styled(Box)({ + maxWidth: "80%", + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + '&[data-is-user="true"]': { + alignItems: "flex-end", + }, +}); + +const MessageMetaRow = styled(Box)({ + display: "flex", + gap: 8, + marginBottom: "4px", + paddingInline: "4px", +}); + +const MessageSender = styled(Typography)({ + fontSize: "12px", + fontWeight: 500, + color: "rgba(0,0,0,0.7)", +}); + +const MessageTimestamp = styled(Typography)({ + fontSize: "12px", + color: "rgba(0,0,0,0.54)", +}); + +/** + * Style definitions for message bubbles based on message variant. + */ +const BOT_BUBBLE_STYLES: Record = { + default: { + backgroundColor: "#F5F5F5", + color: "#212121", + }, + info: { + backgroundColor: "#DCEEFB", + color: "#0B2540", + }, + error: { + backgroundColor: "#C05239", + color: "#FFFFFF", + fontWeight: 600, + }, +}; + +const MessageBubble = styled(Box, { + shouldForwardProp: (prop) => prop !== "variant", +})<{ variant?: ChatMessageVariant }>(({ variant }) => { + const safeVariant = (variant ?? "default") as ChatMessageVariant; + const style = BOT_BUBBLE_STYLES[safeVariant]; + + return { + paddingInline: "12px", + paddingBlock: "8px", + borderRadius: "12px", + backgroundColor: style.backgroundColor, + border: style.border ?? "none", + color: style.color, + fontWeight: style.fontWeight ?? 400, + fontSize: "16px", + lineHeight: 1.5, + + '&[data-is-user="true"]': { + borderTopRightRadius: 0, + backgroundColor: "#005EA2", + color: "#FFFFFF", + }, + + '&[data-is-user="false"]': { + borderTopLeftRadius: 0, + }, + }; +}); + +/** + * Formats a date object into a localized time string. + * + * @param date - The date to format + * @return Formatted time string in 12-hour format + * @example "02:30 PM" + */ +export const formatMessageTime = (date: Date): string => + new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }).format(date); + +type Props = { + /** + * The chat message object to render. + */ + message: ChatMessage; +}; + +/** + * Renders a single chat message with sender info, timestamp, and styled bubble. + */ +const ChatMessageItem = ({ message }: Props): JSX.Element => { + if (!message) { + return null; + } + + const isUser = message.sender === "user"; + const dataIsUser = isUser ? "true" : "false"; + + return ( + + + + {!isUser ? {message.senderName} : null} + {formatMessageTime(message.timestamp)} + + + + {message.text} + + + + ); +}; + +export default React.memo(ChatMessageItem); diff --git a/src/components/ChatBot/panel/MessageList.tsx b/src/components/ChatBot/panel/MessageList.tsx new file mode 100644 index 000000000..030d2d8b1 --- /dev/null +++ b/src/components/ChatBot/panel/MessageList.tsx @@ -0,0 +1,88 @@ +import { Box, Typography, styled } from "@mui/material"; +import React, { useEffect, useRef } from "react"; + +import BotTypingIndicator from "./BotTypingIndicator"; +import ChatMessageItem from "./ChatMessageItem"; + +const MessagesContainer = styled(Box)({ + flex: 1, + overflowY: "auto", + paddingInline: "16px", + paddingBottom: "16px", + overscrollBehavior: "contain", +}); + +const ChatHeader = styled(Box)({ + textAlign: "center", + paddingInline: "16px", + paddingBlock: "16px", +}); + +const ChatTitle = styled(Typography)({ + fontWeight: 700, + marginBottom: 0, +}); + +const ChatSubtitle = styled(Typography)({ + fontSize: "12px", + color: "rgba(0,0,0,0.54)", +}); + +const formatGreetingDateTime = (date: Date): string => + new Intl.DateTimeFormat("en-US", { + weekday: "long", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }).format(date); + +export type Props = { + /** + * The timestamp when the chat session started, displayed in the greeting header. + */ + greetingTimestamp: Date; + /** + * Array of chat messages to display in the message list. + */ + messages: readonly ChatMessage[]; + /** + * Indicates whether the bot is currently typing a response. + */ + isBotTyping: boolean; +}; + +/** + * Displays a scrollable list of chat messages with automatic scrolling to the latest message. + */ +const MessageList = ({ greetingTimestamp, messages, isBotTyping }: Props): JSX.Element => { + const messagesContainerRef = useRef(null); + + const lastMessageId = messages?.length > 0 ? messages[messages.length - 1]?.id : null; + + useEffect(() => { + const element = messagesContainerRef.current; + if (!element) { + return; + } + + element.scrollTo({ + top: element.scrollHeight, + behavior: "smooth", + }); + }, [lastMessageId, isBotTyping]); + + return ( + + + Welcome to Support! + {formatGreetingDateTime(greetingTimestamp)} + + + {messages?.map((message) => )} + + {isBotTyping ? : null} + + ); +}; + +export default React.memo(MessageList); From 52a0ed86db373b096c4002c65eaae9d022470ae3 Mon Sep 17 00:00:00 2001 From: Alejandro-Vega Date: Mon, 22 Dec 2025 11:20:58 -0500 Subject: [PATCH 008/120] init: chatbot view with floating button and drawer --- src/components/ChatBot/ChatBotView.tsx | 106 +++++++ src/components/ChatBot/ChatDrawer.tsx | 232 ++++++++++++++ src/components/ChatBot/Controller.tsx | 21 ++ src/components/ChatBot/FloatingChatButton.tsx | 58 ++++ .../ChatBot/hooks/useChatConversation.ts | 229 ++++++++++++++ src/components/ChatBot/hooks/useChatDrawer.ts | 286 ++++++++++++++++++ src/components/ChatBot/index.tsx | 3 + src/layouts/index.tsx | 3 + 8 files changed, 938 insertions(+) create mode 100644 src/components/ChatBot/ChatBotView.tsx create mode 100644 src/components/ChatBot/ChatDrawer.tsx create mode 100644 src/components/ChatBot/Controller.tsx create mode 100644 src/components/ChatBot/FloatingChatButton.tsx create mode 100644 src/components/ChatBot/hooks/useChatConversation.ts create mode 100644 src/components/ChatBot/hooks/useChatDrawer.ts create mode 100644 src/components/ChatBot/index.tsx diff --git a/src/components/ChatBot/ChatBotView.tsx b/src/components/ChatBot/ChatBotView.tsx new file mode 100644 index 000000000..9e23f981d --- /dev/null +++ b/src/components/ChatBot/ChatBotView.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useState } from "react"; + +import ChatDrawer from "./ChatDrawer"; +import ChatPanel from "./ChatPanel"; +import FloatingChatButton from "./FloatingChatButton"; +import { useChatConversation } from "./hooks/useChatConversation"; +import { useChatDrawer } from "./hooks/useChatDrawer"; + +export type Props = { + /** + * The floating button label. + */ + label?: string; + /** + * The title appearing within the chat. + */ + title?: string; +}; + +/** + * ChatBot component manages the chat interface including the floating button, drawer, and panel states. + */ +const ChatBot = ({ label = "Chat", title = "Chat" }: Props): JSX.Element => { + const { + drawerRef, + isOpen, + isDragging, + isExpanded, + drawerHeightPx, + openDrawer, + closeDrawer, + beginResize, + toggleExpand, + } = useChatDrawer(); + + const { endConversation } = useChatConversation(); + + const [isMinimized, setIsMinimized] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + + /** + * Opens the chat drawer and removes the minimized state when the floating button is clicked. + */ + const handleOpenDrawer = useCallback((): void => { + setIsMinimized(false); + + if (!isOpen) { + openDrawer(); + } + }, [isOpen, openDrawer]); + + /** + * Minimizes the chat drawer when the minimize button is clicked. + */ + const handleMinimizeDrawer = useCallback((): void => { + if (!isOpen) { + return; + } + + setIsMinimized(true); + }, [isOpen]); + + /** + * Toggles the fullscreen state of the chat drawer. + */ + const handleToggleFullscreen = useCallback((): void => { + setIsFullscreen((prev) => !prev); + }, []); + + /** + * Closes the chat drawer, clears the session, and resets state when the conversation ends. + */ + const handleEndConversation = useCallback((): void => { + endConversation(); + setIsMinimized(false); + setIsFullscreen(false); + closeDrawer(); + }, [endConversation, closeDrawer]); + + return ( + <> + + + {isOpen ? ( + + + + ) : null} + + ); +}; + +export default React.memo(ChatBot); diff --git a/src/components/ChatBot/ChatDrawer.tsx b/src/components/ChatBot/ChatDrawer.tsx new file mode 100644 index 000000000..26c1a786f --- /dev/null +++ b/src/components/ChatBot/ChatDrawer.tsx @@ -0,0 +1,232 @@ +import CloseIcon from "@mui/icons-material/Close"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import FullscreenIcon from "@mui/icons-material/Fullscreen"; +import FullscreenExitIcon from "@mui/icons-material/FullscreenExit"; +import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule"; +import { IconButton, Paper, Typography, styled } from "@mui/material"; +import React from "react"; + +const StyledChatDrawer = styled(Paper, { + shouldForwardProp: (prop) => prop !== "heightPx", +})<{ heightPx: number }>(({ heightPx }) => ({ + position: "fixed", + right: 0, + bottom: 0, + width: "384px", + height: heightPx, + borderRadius: "24px 0 0 0", + zIndex: 12000, + display: "flex", + flexDirection: "column", + boxShadow: "0 12px 40px rgba(0,0,0,0.25)", + overflow: "hidden", + backgroundColor: "#ffffff", + border: 0, + opacity: 1, + pointerEvents: "auto", + '&[data-minimized="true"]': { + opacity: 0, + pointerEvents: "none", + }, + + '&[data-fullscreen="true"]': { + inset: 0, + width: "100%", + height: "100%", + borderRadius: 0, + }, +})); + +const StyledChatHeaderContainer = styled("div")({ + backgroundColor: "#005EA2", +}); + +const StyledDragHandleContainer = styled("div")({ + height: "8px", + display: "flex", + alignItems: "center", + justifyContent: "center", + paddingBlock: "12px", + cursor: "ns-resize", + transition: "background-color 0.2s ease-out", + backgroundColor: "transparent", + '&[data-dragging="true"]': { + backgroundColor: "rgba(0,94,162,0.95)", + }, +}); + +const StyledDragHandleBar = styled("div")({ + width: "32px", + height: "4px", + borderRadius: "4px", + backgroundColor: "white", + transition: "opacity 0.2s ease-out, background-color 0.2s ease-out", +}); + +const StyledChatHeader = styled("div")({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "4px 16px", + borderBottom: "1px solid rgba(0,0,0,0.12)", + backgroundColor: "transparent", + color: "white", +}); + +const StyledHeaderActions = styled("div")({ + display: "flex", + gap: 0, +}); + +const StyledChatBody = styled("div")({ + flex: 1, + display: "flex", + flexDirection: "column", + overflow: "hidden", +}); + +const StyledChatTitle = styled(Typography)({ + fontSize: "18px", + fontWeight: 600, + margin: 0, +}); + +const StyledIconButton = styled(IconButton)({ + color: "white", +}); + +export type Props = { + /** + * Height of the drawer in pixels. + */ + heightPx: number; + /** + * Indicates if the drawer is currently being resized. + */ + isDragging: boolean; + /** + * Indicates if the drawer content is expanded or collapsed. + */ + isExpanded: boolean; + /** + * Indicates if the drawer is hidden from view. + */ + isMinimized: boolean; + /** + * Indicates if the drawer is in fullscreen mode. + */ + isFullscreen: boolean; + /** + * Title text displayed in the drawer header. + */ + title: string; + /** + * Callback fired when user begins resizing the drawer. + */ + onBeginResize: React.PointerEventHandler; + /** + * Callback fired when user toggles expand/collapse state. + */ + onToggleExpand: () => void; + /** + * Callback fired when user toggles fullscreen mode. + */ + onToggleFullscreen: () => void; + /** + * Callback fired when user minimizes the drawer. + */ + onMinimize: () => void; + /** + * Callback fired when user ends the conversation. + */ + onEndConversation: () => void; + /** + * Child content rendered in the drawer body. + */ + children: React.ReactNode; +}; + +/** + * ChatDrawer component provides a resizable, draggable chat interface with fullscreen and minimize capabilities. + */ +const ChatDrawer = ( + { + heightPx, + isDragging, + isExpanded, + isMinimized, + isFullscreen, + title, + onBeginResize, + onToggleExpand, + onToggleFullscreen, + onMinimize, + onEndConversation, + children, + }: Props, + ref: React.ForwardedRef +): JSX.Element => ( + + + {!isFullscreen ? ( + + + + ) : null} + + + {title} + + + {!isFullscreen ? ( + + {isExpanded ? ( + + ) : ( + + )} + + ) : null} + + + {isFullscreen ? ( + + ) : ( + + )} + + + + + + + + + + + + + + {children} + +); + +export default React.memo(React.forwardRef(ChatDrawer)); diff --git a/src/components/ChatBot/Controller.tsx b/src/components/ChatBot/Controller.tsx new file mode 100644 index 000000000..36c01dd7f --- /dev/null +++ b/src/components/ChatBot/Controller.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; + +import env from "@/env"; + +import ChatBot, { Props } from "./ChatBotView"; + +/** + * Controls the visibility of the ChatBot component. + */ +const ChatController: FC = (props) => { + const chatbotValue = env.VITE_CHATBOT_ENABLED ?? "true"; + const chatbotEnabled = chatbotValue.toLowerCase() === "true"; + + if (!chatbotEnabled) { + return null; + } + + return ; +}; + +export default ChatController; diff --git a/src/components/ChatBot/FloatingChatButton.tsx b/src/components/ChatBot/FloatingChatButton.tsx new file mode 100644 index 000000000..881e243fd --- /dev/null +++ b/src/components/ChatBot/FloatingChatButton.tsx @@ -0,0 +1,58 @@ +import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; +import { Button, Typography, styled } from "@mui/material"; +import React from "react"; + +const StyledFloatingButton = styled(Button)({ + position: "fixed", + right: 0, + top: "85%", + transform: "translateY(-50%)", + zIndex: 10000, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "8px", + paddingInline: "20px", + paddingBlock: "16px", + borderRadius: "16px 0 0 16px", + backgroundImage: "linear-gradient(to bottom, #005EA2, #1A8199)", + color: "#ffffff", + boxShadow: "0 8px 16px rgba(0, 0, 0, 0.25)", + borderLeft: "4px solid #0f4555", + textTransform: "none", + transition: "transform 0.3s ease-out, box-shadow 0.3s ease-out", + "&:hover": { + boxShadow: "0 12px 24px rgba(0, 0, 0, 0.35)", + transform: "translateY(-50%) scale(1.05)", + backgroundImage: "linear-gradient(to bottom, #005EA2, #1A8199)", + }, + "&:active": { + transform: "translateY(-50%) scale(0.95)", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)", + }, +}); + +const StyledChatIcon = styled(ChatBubbleOutlineIcon)({ + width: "24px", + height: "24px", +}); + +const StyledLabel = styled(Typography)({ + fontWeight: 700, + letterSpacing: "1px", +}); + +export type Props = { + label: string; + onClick: React.MouseEventHandler; +}; + +const FloatingChatButton = ({ label, onClick }: Props): JSX.Element => ( + + + {label} + +); + +export default React.memo(FloatingChatButton); diff --git a/src/components/ChatBot/hooks/useChatConversation.ts b/src/components/ChatBot/hooks/useChatConversation.ts new file mode 100644 index 000000000..8b007cec1 --- /dev/null +++ b/src/components/ChatBot/hooks/useChatConversation.ts @@ -0,0 +1,229 @@ +import React, { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; + +import { + askKnowledgeBase, + clearStoredSessionId, + getStoredSessionId, +} from "../api/knowledgeBaseClient"; +import chatConfig from "../chatConfig"; +import { createChatMessage, createId, isAbortError } from "../utils/chatUtils"; + +type ChatState = { + messages: ChatMessage[]; + inputValue: string; + status: ChatStatus; +}; + +type ChatAction = + | { type: "input_changed"; value: string } + | { type: "input_cleared" } + | { type: "message_added"; message: ChatMessage } + | { type: "status_changed"; status: ChatStatus }; + +type BotReplyProvider = (userText: string, signal: AbortSignal) => Promise; + +type ChatConversationActions = { + greetingTimestamp: Date; + messages: ChatMessage[]; + inputValue: string; + isBotTyping: boolean; + setInputValue: (value: string) => void; + sendMessage: () => void; + handleKeyDown: React.KeyboardEventHandler; + endConversation: () => void; +}; + +/** + * Chat reducer to manage chat state transitions. + * + * @param state - The current chat state + * @param action - The Action to process + * @returns The updated chat state + */ +const chatReducer = (state: ChatState, action: ChatAction): ChatState => { + switch (action.type) { + case "input_changed": { + return { ...state, inputValue: action.value }; + } + case "input_cleared": { + return { ...state, inputValue: "" }; + } + case "message_added": { + return { ...state, messages: [...state.messages, action.message] }; + } + case "status_changed": { + return { ...state, status: action.status }; + } + default: { + return state; + } + } +}; + +/** + * Custom hook to manage chat conversation state and behavior. + * + * @returns An object containing chat state and action handlers. + */ +export const useChatConversation = (): ChatConversationActions => { + const greetingTimestampRef = useRef(new Date()); + + const [state, dispatch] = useReducer(chatReducer, { + messages: [ + createChatMessage({ + text: chatConfig.initialMessage, + sender: "bot", + senderName: chatConfig.supportBotName, + }), + ], + inputValue: "", + status: "idle", + }); + + const stateRef = useRef(state); + stateRef.current = state; + + const activeRequestRef = useRef<{ + requestId: string; + abortController: AbortController; + } | null>(null); + + const replyProvider = useMemo( + () => async (userText, signal) => { + const storedSessionId = getStoredSessionId(); + const { answer } = await askKnowledgeBase({ + question: userText, + sessionId: storedSessionId, + signal, + }); + + return answer; + }, + [] + ); + + useEffect( + () => () => { + activeRequestRef.current?.abortController.abort(); + activeRequestRef.current = null; + }, + [] + ); + + const setInputValue = useCallback((value: string): void => { + dispatch({ type: "input_changed", value }); + }, []); + + const sendMessage = useCallback((): void => { + const { current } = stateRef; + const trimmed = current.inputValue.trim(); + + if (!trimmed) { + return; + } + + if (current.status === "bot_typing") { + return; + } + + dispatch({ + type: "message_added", + message: createChatMessage({ + text: trimmed, + sender: "user", + senderName: chatConfig.userDisplayName, + }), + }); + + dispatch({ type: "input_cleared" }); + dispatch({ type: "status_changed", status: "bot_typing" }); + + activeRequestRef.current?.abortController.abort(); + + const abortController = new AbortController(); + const requestId = createId("bot_reply_"); + activeRequestRef.current = { requestId, abortController }; + + const runReply = async (): Promise => { + try { + const replyText = await replyProvider(trimmed, abortController.signal); + + const active = activeRequestRef.current; + if (!active || active.requestId !== requestId || active.abortController.signal.aborted) { + return; + } + + dispatch({ + type: "message_added", + message: createChatMessage({ + text: replyText, + sender: "bot", + senderName: chatConfig.supportBotName, + }), + }); + + dispatch({ type: "status_changed", status: "idle" }); + } catch (error) { + const active = activeRequestRef.current; + if (!active || active.requestId !== requestId) { + return; + } + + if (active.abortController.signal.aborted || isAbortError(error)) { + return; + } + + dispatch({ + type: "message_added", + message: createChatMessage({ + text: "Sorry, an unexpected error occurred. Please try again later.", + sender: "bot", + senderName: chatConfig.supportBotName, + variant: "error", + }), + }); + + dispatch({ type: "status_changed", status: "idle" }); + } + }; + + runReply().catch((error: unknown) => { + if (!isAbortError(error)) { + dispatch({ type: "status_changed", status: "idle" }); + } + }); + }, [replyProvider]); + + const handleKeyDown: React.KeyboardEventHandler = useCallback( + (event) => { + if (event.key !== "Enter") { + return; + } + + if (event.shiftKey) { + return; + } + + event.preventDefault(); + sendMessage(); + }, + [sendMessage] + ); + + const endConversation = useCallback((): void => { + clearStoredSessionId(); + activeRequestRef.current?.abortController.abort(); + activeRequestRef.current = null; + }, []); + + return { + greetingTimestamp: greetingTimestampRef.current, + messages: state.messages, + inputValue: state.inputValue, + isBotTyping: state.status === "bot_typing", + setInputValue, + sendMessage, + handleKeyDown, + endConversation, + }; +}; diff --git a/src/components/ChatBot/hooks/useChatDrawer.ts b/src/components/ChatBot/hooks/useChatDrawer.ts new file mode 100644 index 000000000..fa4ca39d1 --- /dev/null +++ b/src/components/ChatBot/hooks/useChatDrawer.ts @@ -0,0 +1,286 @@ +import React, { useCallback, useEffect, useReducer, useRef } from "react"; + +import chatConfig from "../chatConfig"; +import { computeNextHeightPx, getViewportHeightPx } from "../utils/chatUtils"; + +type DrawerState = { + /** + * Indicates whether the drawer is currently open. + */ + isOpen: boolean; + /** + * Indicates whether the drawer is currently being dragged/resized. + */ + isDragging: boolean; + /** + * Indicates whether the drawer is currently expanded to its maximum height. + */ + isExpanded: boolean; + /** + * The current height of the drawer in pixels. + */ + heightPx: number; +}; + +type DrawerAction = + | { type: "opened" } + | { type: "closed" } + | { type: "drag_started" } + | { type: "drag_ended" } + | { type: "height_changed"; heightPx: number; viewportHeightPx: number } + | { type: "expand_toggled"; viewportHeightPx: number }; + +/** + * Reducer function to manage the state of the chat drawer. + * + * @param {DrawerState} state - The current state of the chat drawer. + * @param {DrawerAction} action - The action to be performed on the chat drawer state. + * @returns The new state of the chat drawer after applying the action. + */ +const reducer = (state: DrawerState, action: DrawerAction): DrawerState => { + switch (action.type) { + case "opened": { + if (state.isOpen) { + return state; + } + + return { + isOpen: true, + isDragging: false, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + }; + } + case "closed": { + if (!state.isOpen) { + return state; + } + + return { + ...state, + isOpen: false, + isDragging: false, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + }; + } + case "drag_started": { + if (!state.isOpen || state.isDragging) { + return state; + } + + return { ...state, isDragging: true }; + } + case "drag_ended": { + if (!state.isDragging) { + return state; + } + + return { ...state, isDragging: false }; + } + case "height_changed": { + const isNearMax = + action.viewportHeightPx - action.heightPx <= chatConfig.height.expandedSnapThreshold; + return { + ...state, + heightPx: action.heightPx, + isExpanded: isNearMax, + }; + } + case "expand_toggled": { + if (!state.isOpen) { + return state; + } + + if (state.isExpanded) { + return { + ...state, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + }; + } + + return { + ...state, + isExpanded: true, + heightPx: action.viewportHeightPx, + }; + } + default: { + return state; + } + } +}; + +type Result = { + drawerRef: React.RefObject; + isOpen: boolean; + isDragging: boolean; + isExpanded: boolean; + drawerHeightPx: number; + openDrawer: () => void; + closeDrawer: () => void; + beginResize: React.PointerEventHandler; + toggleExpand: () => void; +}; + +/** + * Custom hook to manage the state and behavior of the chat drawer. + * + * @returns An object containing the state and actions for the chat drawer. + */ +export const useChatDrawer = (): Result => { + const drawerRef = useRef(null); + + const [state, dispatch] = useReducer(reducer, { + isOpen: false, + isDragging: false, + isExpanded: false, + heightPx: chatConfig.height.collapsed, + }); + + const stateRef = useRef(state); + stateRef.current = state; + + /** + * These refs ensure the global event handlers have immediate, up-to-date state + * without needing to re-register listeners. + */ + const isDraggingRef = useRef(false); + /** + * Stores the ID of the active pointer during a drag operation. + */ + const activePointerIdRef = useRef(null); + + /** + * Opens the chat drawer. + */ + const openDrawer = useCallback((): void => { + dispatch({ type: "opened" }); + }, []); + + /** + * Closes the chat drawer. + */ + const closeDrawer = useCallback((): void => { + isDraggingRef.current = false; + activePointerIdRef.current = null; + dispatch({ type: "closed" }); + }, []); + + /** + * Toggles the expanded state of the chat drawer. + */ + const toggleExpand = useCallback((): void => { + const viewportHeightPx = getViewportHeightPx(chatConfig.height.collapsed); + dispatch({ type: "expand_toggled", viewportHeightPx }); + }, []); + + /** + * Begins the resize operation for the chat drawer. + */ + const beginResize: React.PointerEventHandler = useCallback((event): void => { + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + + if (!stateRef.current.isOpen) { + return; + } + + event.preventDefault(); + + isDraggingRef.current = true; + activePointerIdRef.current = event.pointerId; + + dispatch({ type: "drag_started" }); + }, []); + + /** + * Applies the resize, given a pointer Y position. + */ + const applyResize = useCallback((clientY: number): void => { + const drawerElement = drawerRef.current; + if (!drawerElement) { + return; + } + + const next = computeNextHeightPx({ drawerElement, clientY }); + dispatch({ + type: "height_changed", + heightPx: next.heightPx, + viewportHeightPx: next.viewportHeightPx, + }); + }, []); + + /** + * Handles the pointer move event on the window. + */ + const handleWindowPointerMove = useCallback( + (event: PointerEvent): void => { + if (!isDraggingRef.current) { + return; + } + + const activePointerId = activePointerIdRef.current; + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + + applyResize(event.clientY); + }, + [applyResize] + ); + + /** + * Ends the drag operation for the chat drawer. + */ + const endDrag = useCallback((): void => { + if (!isDraggingRef.current) { + return; + } + + isDraggingRef.current = false; + activePointerIdRef.current = null; + + dispatch({ type: "drag_ended" }); + }, []); + + /** + * Handles the pointer up event on the window. + */ + const handleWindowPointerUp = useCallback( + (event: PointerEvent): void => { + const activePointerId = activePointerIdRef.current; + + if (activePointerId === null || event.pointerId === activePointerId) { + endDrag(); + } + }, + [endDrag] + ); + + useEffect(() => { + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + }; + }, [handleWindowPointerMove, handleWindowPointerUp]); + + return { + drawerRef, + isOpen: state.isOpen, + isDragging: state.isDragging, + isExpanded: state.isExpanded, + drawerHeightPx: state.heightPx, + openDrawer, + closeDrawer, + beginResize, + toggleExpand, + }; +}; diff --git a/src/components/ChatBot/index.tsx b/src/components/ChatBot/index.tsx new file mode 100644 index 000000000..3f6006fcb --- /dev/null +++ b/src/components/ChatBot/index.tsx @@ -0,0 +1,3 @@ +import ChatController from "./Controller"; + +export default ChatController; diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 6fcdacc97..7f57a9cd7 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -2,6 +2,8 @@ import { styled } from "@mui/material"; import { FC, ReactNode } from "react"; import { Outlet, ScrollRestoration } from "react-router-dom"; +import ChatBot from "@/components/ChatBot"; + import { SearchParamsProvider } from "../components/Contexts/SearchParamsContext"; import Footer from "../components/Footer"; import Header from "../components/Header"; @@ -28,6 +30,7 @@ const Layout: FC = ({ children }) => (