diff --git a/services/course-material/src/components/chatbot/ChatbotDialogBody.tsx b/services/course-material/src/components/chatbot/ChatbotDialogBody.tsx index 5a9956138a44..8169a7640ced 100644 --- a/services/course-material/src/components/chatbot/ChatbotDialogBody.tsx +++ b/services/course-material/src/components/chatbot/ChatbotDialogBody.tsx @@ -1,12 +1,12 @@ import { css } from "@emotion/css" import { UseQueryResult } from "@tanstack/react-query" import { PaperAirplane } from "@vectopus/atlas-icons-react" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useReducer, useRef } from "react" import { useTranslation } from "react-i18next" import { v4 } from "uuid" import { ChatbotDialogProps } from "./ChatbotDialog" -import ThinkingIndicator from "./ThinkingIndicator" +import MessageBubble from "./MessageBubble" import { newChatbotConversation, sendChatbotMessage } from "@/services/backend" import { ChatbotConversationInfo } from "@/shared-module/common/bindings" @@ -16,16 +16,40 @@ import Spinner from "@/shared-module/common/components/Spinner" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" import { baseTheme } from "@/shared-module/common/styles" +interface MessageState { + optimisticMessage: string | null + streamingMessage: string | null +} + +type MessageAction = + | { type: "SET_OPTIMISTIC_MESSAGE"; payload: string | null } + | { type: "APPEND_STREAMING_MESSAGE"; payload: string } + | { type: "RESET_MESSAGES" } + +const messageReducer = (state: MessageState, action: MessageAction): MessageState => { + switch (action.type) { + case "SET_OPTIMISTIC_MESSAGE": + return { ...state, optimisticMessage: action.payload } + case "APPEND_STREAMING_MESSAGE": + return { ...state, streamingMessage: (state.streamingMessage || "") + action.payload } + case "RESET_MESSAGES": + return { optimisticMessage: null, streamingMessage: null } + default: + return state + } +} + const ChatbotDialogBody: React.FC< ChatbotDialogProps & { currentConversationInfo: UseQueryResult } > = ({ currentConversationInfo, chatbotConfigurationId }) => { const scrollContainerRef = useRef(null) - const { t } = useTranslation() - const [newMessage, setNewMessage] = useState("") - const [optimisticSentMessage, setOptimisticSentMessage] = useState(null) - const [streamingMessage, setStreamingMessage] = useState(null) + const [newMessage, setNewMessage] = React.useState("") + const [messageState, dispatch] = useReducer(messageReducer, { + optimisticMessage: null, + streamingMessage: null, + }) const newConversationMutation = useToastMutation( () => newChatbotConversation(chatbotConfigurationId), @@ -33,6 +57,7 @@ const ChatbotDialogBody: React.FC< { onSuccess: () => { currentConversationInfo.refetch() + dispatch({ type: "RESET_MESSAGES" }) }, }, ) @@ -42,8 +67,8 @@ const ChatbotDialogBody: React.FC< if (!currentConversationInfo.data?.current_conversation) { throw new Error("No active conversation") } - const message = newMessage - setOptimisticSentMessage(message) + const message = newMessage.trim() + dispatch({ type: "SET_OPTIMISTIC_MESSAGE", payload: message }) setNewMessage("") const stream = await sendChatbotMessage( chatbotConfigurationId, @@ -53,25 +78,24 @@ const ChatbotDialogBody: React.FC< const reader = stream.getReader() let done = false - let value = undefined while (!done) { - ;({ done, value } = await reader.read()) - const valueAsString = new TextDecoder().decode(value) - const lines = valueAsString.split("\n") - for (const line of lines) { - if (line?.indexOf("{") !== 0) { - continue - } - console.log(line) - try { - const parsedValue = JSON.parse(line) - console.log(parsedValue) - if (parsedValue.text) { - setStreamingMessage((prev) => `${prev || ""}${parsedValue.text}`) + const { done: doneReading, value } = await reader.read() + done = doneReading + if (value) { + const valueAsString = new TextDecoder().decode(value) + const lines = valueAsString.split("\n") + for (const line of lines) { + if (line?.indexOf("{") !== 0) { + continue + } + try { + const parsedValue = JSON.parse(line) + if (parsedValue.text) { + dispatch({ type: "APPEND_STREAMING_MESSAGE", payload: parsedValue.text }) + } + } catch (e) { + console.error(e) } - } catch (e) { - // NOP - console.error(e) } } } @@ -79,22 +103,20 @@ const ChatbotDialogBody: React.FC< }, { notify: false }, { - onSuccess: async (_stream) => { + onSuccess: async () => { await currentConversationInfo.refetch() - setStreamingMessage(null) - setOptimisticSentMessage(null) + dispatch({ type: "RESET_MESSAGES" }) }, }, ) const messages = useMemo(() => { const messages = [...(currentConversationInfo.data?.current_conversation_messages ?? [])] - const lastOrderNumber = Math.max(...messages.map((m) => m.order_number)) - if (optimisticSentMessage) { + const lastOrderNumber = Math.max(...messages.map((m) => m.order_number), 0) + if (messageState.optimisticMessage) { messages.push({ - // eslint-disable-next-line i18next/no-literal-string id: v4(), - message: optimisticSentMessage, + message: messageState.optimisticMessage, is_from_chatbot: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -105,11 +127,10 @@ const ChatbotDialogBody: React.FC< order_number: lastOrderNumber + 1, }) } - if (streamingMessage) { + if (messageState.streamingMessage) { messages.push({ - // eslint-disable-next-line i18next/no-literal-string id: v4(), - message: streamingMessage, + message: messageState.streamingMessage, is_from_chatbot: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -124,19 +145,17 @@ const ChatbotDialogBody: React.FC< }, [ currentConversationInfo.data?.current_conversation?.id, currentConversationInfo.data?.current_conversation_messages, - optimisticSentMessage, - streamingMessage, + messageState.optimisticMessage, + messageState.streamingMessage, ]) const scrollToBottom = useCallback(() => { - if (!scrollContainerRef.current) { - return + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight } - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight - }, [scrollContainerRef]) + }, []) useEffect(() => { - // Whenever the messages change, scroll to the bottom scrollToBottom() }, [scrollToBottom, messages]) @@ -150,11 +169,28 @@ const ChatbotDialogBody: React.FC< } if (currentConversationInfo.isError) { - return + return ( +
+ + +
+ ) } if (currentConversationInfo && !currentConversationInfo.data?.current_conversation) { - // The chatbot has loaded, but no conversation is active. Show the warning message. return (

{t("about-the-chatbot")}

-

{t("chatbot-disclaimer-start")}

-
  • {t("chatbot-discalimer-sensitive-information")}
  • {t("chatbot-disclaimer-check")}
  • @@ -204,7 +238,6 @@ const ChatbotDialogBody: React.FC<
- @@ -232,37 +265,18 @@ const ChatbotDialogBody: React.FC< ref={scrollContainerRef} > {messages.map((message) => ( -
- {message.message} - {!message.message_is_complete && newMessageMutation.isPending && ( - - )} -
+ message={message.message ?? ""} + isFromChatbot={message.is_from_chatbot} + isPending={!message.message_is_complete && newMessageMutation.isPending} + /> ))}
setNewMessage(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() if (canSubmit) { newMessageMutation.mutate() @@ -305,9 +318,7 @@ const ChatbotDialogBody: React.FC< display: flex; align-items: center; justify-content: center; - padding: 0.3rem 0.6rem; - transition: filter 0.2s; &:disabled { @@ -318,11 +329,11 @@ const ChatbotDialogBody: React.FC< &:hover { filter: brightness(0.9) contrast(1.1); } + svg { position: relative; top: 0px; left: -2px; - transform: rotate(45deg); } `} @@ -348,4 +359,4 @@ const ChatbotDialogBody: React.FC< ) } -export default ChatbotDialogBody +export default React.memo(ChatbotDialogBody) diff --git a/services/course-material/src/components/chatbot/ChatbotDialogHeader.tsx b/services/course-material/src/components/chatbot/ChatbotDialogHeader.tsx index 16e54f9fa55f..4fff1a809c27 100644 --- a/services/course-material/src/components/chatbot/ChatbotDialogHeader.tsx +++ b/services/course-material/src/components/chatbot/ChatbotDialogHeader.tsx @@ -1,6 +1,7 @@ import { css } from "@emotion/css" import { UseQueryResult } from "@tanstack/react-query" import { Account, AddMessage } from "@vectopus/atlas-icons-react" +import React from "react" import { useTranslation } from "react-i18next" import { ChatbotDialogProps } from "./ChatbotDialog" @@ -11,9 +12,58 @@ import useToastMutation from "@/shared-module/common/hooks/useToastMutation" import DownIcon from "@/shared-module/common/img/down.svg" import { baseTheme } from "@/shared-module/common/styles" -const ChatbotDialogHeader: React.FC< - ChatbotDialogProps & { currentConversationInfo: UseQueryResult } -> = ({ setDialogOpen, currentConversationInfo, chatbotConfigurationId }) => { +interface ChatbotDialogHeaderProps extends ChatbotDialogProps { + currentConversationInfo: UseQueryResult +} + +const headerContainerStyle = css` + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + background-color: ${baseTheme.colors.gray[100]}; + border-radius: 10px 10px 0px 0px; +` + +// eslint-disable-next-line i18next/no-literal-string +const iconStyle = css` + background-color: ${baseTheme.colors.clear[200]}; + color: ${baseTheme.colors.gray[400]}; + display: flex; + justify-content: center; + align-items: center; + padding: 0.5rem; + border-radius: 50%; + margin-right: 1rem; +` + +const titleStyle = css` + font-style: normal; + font-weight: 500; + font-size: 22px; + line-height: 130%; +` + +const buttonStyle = css` + font-size: 20px; + cursor: pointer; + background-color: transparent; + border-radius: 50%; + border: none; + margin: 0 0.5rem; + color: ${baseTheme.colors.gray[400]}; + transition: filter 0.2s; + + &:hover { + filter: brightness(0.7) contrast(1.1); + } +` + +const ChatbotDialogHeader: React.FC = ({ + setDialogOpen, + currentConversationInfo, + chatbotConfigurationId, +}) => { const { t } = useTranslation() const newConversationMutation = useToastMutation( @@ -27,86 +77,34 @@ const ChatbotDialogHeader: React.FC< ) return ( -
-
+
+
-

- {currentConversationInfo.data?.chatbot_name} -

- - +

{currentConversationInfo.data?.chatbot_name}

+
+ + +
) } -export default ChatbotDialogHeader +export default React.memo(ChatbotDialogHeader) diff --git a/services/course-material/src/components/chatbot/MessageBubble.tsx b/services/course-material/src/components/chatbot/MessageBubble.tsx new file mode 100644 index 000000000000..2338d6b787e0 --- /dev/null +++ b/services/course-material/src/components/chatbot/MessageBubble.tsx @@ -0,0 +1,45 @@ +import { css } from "@emotion/css" +import React from "react" + +import ThinkingIndicator from "./ThinkingIndicator" + +import { baseTheme } from "@/shared-module/common/styles" + +interface MessageBubbleProps { + message: string + isFromChatbot: boolean + isPending: boolean +} + +const bubbleStyle = (isFromChatbot: boolean) => css` + padding: 1rem; + border-radius: 10px; + width: fit-content; + margin: 0.5rem 0; + ${isFromChatbot + ? ` + margin-right: 2rem; + align-self: flex-start; + background-color: ${baseTheme.colors.gray[100]}; + ` + : ` + margin-left: 2rem; + align-self: flex-end; + border: 2px solid ${baseTheme.colors.gray[200]}; + `} +` + +const MessageBubble: React.FC = React.memo( + ({ message, isFromChatbot, isPending }) => { + return ( +
+ {message} + {isPending && } +
+ ) + }, +) + +MessageBubble.displayName = "MessageBubble" + +export default MessageBubble diff --git a/services/course-material/src/components/chatbot/OpenChatbotButton.tsx b/services/course-material/src/components/chatbot/OpenChatbotButton.tsx index 93f7bf5e5430..760f477e600e 100644 --- a/services/course-material/src/components/chatbot/OpenChatbotButton.tsx +++ b/services/course-material/src/components/chatbot/OpenChatbotButton.tsx @@ -1,47 +1,50 @@ import { css } from "@emotion/css" import { Conversation } from "@vectopus/atlas-icons-react" +import React from "react" import { useTranslation } from "react-i18next" import { baseTheme } from "@/shared-module/common/styles" interface OpenChatbotButtonProps { - dialogOpen: boolean setDialogOpen: (dialogOpen: boolean) => void } -const OpenChatbotButton: React.FC = ({ dialogOpen, setDialogOpen }) => { +// eslint-disable-next-line i18next/no-literal-string +const buttonStyle = css` + position: fixed; + bottom: 62px; + right: 14px; + z-index: 1000; + background: white; + border-radius: 100px; + width: 60px; + height: 60px; + cursor: pointer; + border: none; + color: white; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + background-color: ${baseTheme.colors.gray[300]}; + + &:hover { + background-color: ${baseTheme.colors.gray[400]}; + } +` + +const OpenChatbotButton: React.FC = ({ setDialogOpen }) => { const { t } = useTranslation() + const handleClick = () => { + setDialogOpen(true) + } + return ( - ) } -export default OpenChatbotButton +export default React.memo(OpenChatbotButton) diff --git a/services/course-material/src/components/chatbot/ThinkingIndicator.tsx b/services/course-material/src/components/chatbot/ThinkingIndicator.tsx index 439e694365f7..52279e572b56 100644 --- a/services/course-material/src/components/chatbot/ThinkingIndicator.tsx +++ b/services/course-material/src/components/chatbot/ThinkingIndicator.tsx @@ -1,6 +1,7 @@ // Modified from https://github.com/vineethtrv/css-loader, MIT import { css, keyframes } from "@emotion/css" import styled from "@emotion/styled" +import React from "react" import { baseTheme } from "@/shared-module/common/styles" @@ -15,15 +16,18 @@ const bounce = keyframes` } ` +interface DotProps { + delaySeconds: number +} + // eslint-disable-next-line i18next/no-literal-string -const Dot = styled.span<{ delaySeconds: number }>` +const Dot = styled.span` display: inline-block; width: 3px; height: 3px; margin: 0 2px; background-color: ${baseTheme.colors.gray[400]}; border-radius: 50%; - animation-name: ${bounce}; animation-duration: 1.3s; animation-timing-function: linear; @@ -31,7 +35,7 @@ const Dot = styled.span<{ delaySeconds: number }>` animation-delay: ${({ delaySeconds }) => delaySeconds}s; ` -const ThinkingIndicator = () => { +const ThinkingIndicator: React.FC = () => { return ( { ) } -export default ThinkingIndicator +export default React.memo(ThinkingIndicator) diff --git a/services/course-material/src/components/chatbot/index.tsx b/services/course-material/src/components/chatbot/index.tsx index c79c161dabf0..1dee22140d6e 100644 --- a/services/course-material/src/components/chatbot/index.tsx +++ b/services/course-material/src/components/chatbot/index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import React, { useState } from "react" import ChatbotDialog from "./ChatbotDialog" import OpenChatbotButton from "./OpenChatbotButton" @@ -12,7 +12,7 @@ const Chatbot: React.FC = ({ chatbotConfigurationId }) => { return ( <> - {!dialogOpen && } + {!dialogOpen && } = ({ chatbotConfigurationId }) => { ) } -export default Chatbot +export default React.memo(Chatbot) diff --git a/services/course-material/src/hooks/chatbot/useCurrentConversationInfo.ts b/services/course-material/src/hooks/chatbot/useCurrentConversationInfo.ts new file mode 100644 index 000000000000..621068122621 --- /dev/null +++ b/services/course-material/src/hooks/chatbot/useCurrentConversationInfo.ts @@ -0,0 +1,14 @@ +// hooks/useCurrentConversationInfo.ts +import { useQuery } from "@tanstack/react-query" + +import { getChatbotCurrentConversationInfo } from "@/services/backend" +import { ChatbotConversationInfo } from "@/shared-module/common/bindings" + +const useCurrentConversationInfo = (chatbotConfigurationId: string) => { + return useQuery({ + queryKey: ["currentConversationInfo", chatbotConfigurationId], + queryFn: () => getChatbotCurrentConversationInfo(chatbotConfigurationId), + }) +} + +export default useCurrentConversationInfo diff --git a/shared-module/packages/common/src/locales/en/course-material.json b/shared-module/packages/common/src/locales/en/course-material.json index 0f9d75bf3285..3c011f21deca 100644 --- a/shared-module/packages/common/src/locales/en/course-material.json +++ b/shared-module/packages/common/src/locales/en/course-material.json @@ -126,10 +126,12 @@ "message-you-have-not-met-the-requirements-for-taking-this-exam": "You have not met the requirements for taking this exam.", "n-characters-left": "{{n}} characters left", "n-characters-over-limit": "{{n}} characters over the limit", + "new-conversation": "New conversation", "no-comments-yet": "No comments yet", "no-submission-received-for-this-exercise": "No submission received for this exercise.", "number-of-student": "Number of students", "open-audio-player-button": "Listen", + "open-chatbot": "Open chatbot", "opens-in-time": "Opens in {{ relative-time }}", "opens-now": "Opens now!", "peer-review": "Peer review", diff --git a/shared-module/packages/common/src/locales/fi/course-material.json b/shared-module/packages/common/src/locales/fi/course-material.json index ab8d1e8a0620..98be5b47025d 100644 --- a/shared-module/packages/common/src/locales/fi/course-material.json +++ b/shared-module/packages/common/src/locales/fi/course-material.json @@ -128,10 +128,12 @@ "message-you-have-not-met-the-requirements-for-taking-this-exam": "Et ole täyttänyt esivaatimuksia tämän kokeen suorittamiseen.", "n-characters-left": "{{n}} merkkiä jäljellä", "n-characters-over-limit": "{{n}} Merkkiä yli rajan", + "new-conversation": "Uusi keskustelu", "no-comments-yet": "Ei kommentteja vielä", "no-submission-received-for-this-exercise": "Tähän tehtävään ei ole vastattu.", "number-of-student": "Opiskelijoiden määrä", "open-audio-player-button": "Kuuntele", + "open-chatbot": "Avaa keskustelurobotti", "opens-in-time": "Avautuu {{ relative-time }}", "opens-now": "Avautuu nyt!", "page": "Sivu",