-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement mentoring page #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: axm-mentoring-app
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| import { useState, useRef, useEffect } from 'react'; | ||
| import { useSelector } from 'react-redux'; | ||
| import classNames from 'classnames'; | ||
| import { useIntl } from '@edx/frontend-platform/i18n'; | ||
| import { getConfig } from '@edx/frontend-platform'; | ||
| import { | ||
| Avatar, Card, Form, StatefulButton, Stack, Alert, Icon, | ||
| } from '@openedx/paragon'; | ||
| import { AutoAwesome as AutoAwesomeIcon, Cancel as CancelIcon } from '@openedx/paragon/icons'; | ||
|
|
||
| import { CHAT_SENDERS, CHAT_STATUSES_MAP } from './constants'; | ||
| import { useWebSocket } from './hooks'; | ||
| import messages from './messages'; | ||
|
|
||
| const ChatWindow = () => { | ||
| const [input, setInput] = useState(''); | ||
| const [thinking, setThinking] = useState(false); | ||
| const [chatMessages, setChatMessages] = useState([]); | ||
| const [status, setStatus] = useState(CHAT_STATUSES_MAP.default); | ||
| const lastMessageRef = useRef(null); | ||
|
|
||
| const intl = useIntl(); | ||
| const { OPENEDX_AI_SOCKET_DOMAIN } = getConfig(); | ||
| const disabledStates = [CHAT_STATUSES_MAP.pending, CHAT_STATUSES_MAP.error]; | ||
| const isNotAllowedToSend = !input.trim() || disabledStates.includes(status); | ||
| const { courseId } = useSelector(state => state.courseHome); | ||
|
|
||
| const { sendMessage } = useWebSocket({ | ||
| url: `ws://${OPENEDX_AI_SOCKET_DOMAIN}/ws/chatgpt/${courseId}/`, | ||
| onMessage: (msg) => { | ||
| setChatMessages((prev) => [...prev, { sender: CHAT_SENDERS.ai, text: msg.text }]); | ||
| setStatus(CHAT_STATUSES_MAP.default); | ||
| setThinking(false); | ||
| }, | ||
| onConnect: () => {}, | ||
| onError: () => { | ||
| setStatus(CHAT_STATUSES_MAP.error); | ||
| setThinking(false); | ||
| }, | ||
| }); | ||
|
|
||
| const handleSend = () => { | ||
| if (!input.trim()) { | ||
| return; | ||
| } | ||
|
|
||
| setChatMessages((prev) => [...prev, { sender: CHAT_SENDERS.student, text: input }]); | ||
| setStatus(CHAT_STATUSES_MAP.pending); | ||
| setThinking(true); | ||
| sendMessage({ text: input }); | ||
| setInput(''); | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| lastMessageRef.current?.scrollIntoView({ behavior: 'smooth' }); | ||
| }, [chatMessages]); | ||
|
|
||
| const getAvatar = (sender) => ( | ||
| sender === CHAT_SENDERS.student ? ( | ||
| <Avatar size="sm" className="flex-shrink-0" /> | ||
| ) : ( | ||
| <AutoAwesomeIcon className="pgn__avatar pgn__avatar-sm flex-shrink-0 p-2 text-gray-900" /> | ||
| )); | ||
|
|
||
| if (!OPENEDX_AI_SOCKET_DOMAIN) { | ||
| return ( | ||
| <Alert variant="danger"> | ||
| {intl.formatMessage(messages.aiSocketDomainMissing)} | ||
| </Alert> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Card className="w-100 mt-4.5"> | ||
| <Card.Body> | ||
| <Stack | ||
| gap={3} | ||
| className="p-3 d-flex flex-column chat-window-wrapper" | ||
| > | ||
| {chatMessages.map((msg, idx) => ( | ||
| <Stack | ||
| key={idx} // eslint-disable-line react/no-array-index-key | ||
| direction="horizontal" | ||
| className={classNames('w-100 fade-in align-items-end', { | ||
| 'justify-content-end': msg.sender === CHAT_SENDERS.student, | ||
| 'justify-content-start': msg.sender === CHAT_SENDERS.student, | ||
| })} | ||
| gap={2} | ||
| ref={idx === chatMessages.length - 1 ? lastMessageRef : null} | ||
| > | ||
| {getAvatar(msg.sender)} | ||
| <div | ||
| className={classNames('p-3 chat-message', { | ||
| 'bg-primary-100 rounded-right rounded-top': msg.sender === CHAT_SENDERS.ai, | ||
| 'bg-primary-500 text-white rounded-left rounded-top order-first': msg.sender !== CHAT_SENDERS.ai, | ||
| })} | ||
| > | ||
| <p className="mb-0">{msg.text}</p> | ||
| </div> | ||
| </Stack> | ||
| ))} | ||
|
|
||
| {thinking && ( | ||
| <Stack gap={2} direction="horizontal"> | ||
| {getAvatar(CHAT_SENDERS.ai)} | ||
| <p className="text-muted m-0"> | ||
| {intl.formatMessage(messages.thinking)} | ||
| </p> | ||
| </Stack> | ||
| )} | ||
| </Stack> | ||
|
|
||
| <Stack direction="horizontal" gap={2} className="m-3"> | ||
| <Form.Control | ||
| value={input} | ||
| size="lg" | ||
| onChange={(e) => setInput(e.target.value)} | ||
| onKeyDown={(e) => e.key === 'Enter' && !isNotAllowedToSend && handleSend()} | ||
| placeholder={intl.formatMessage(messages.placeholder)} | ||
| /> | ||
| <StatefulButton | ||
| state={status} | ||
| size="lg" | ||
| disabled={isNotAllowedToSend} | ||
| onClick={handleSend} | ||
| labels={{ | ||
| [CHAT_STATUSES_MAP.default]: intl.formatMessage(messages.send), | ||
| [CHAT_STATUSES_MAP.error]: intl.formatMessage(messages.error), | ||
| }} | ||
| icons={{ | ||
| [CHAT_STATUSES_MAP.error]: <Icon src={CancelIcon} />, | ||
| }} | ||
| disabledStates={disabledStates} | ||
| /> | ||
| </Stack> | ||
| </Card.Body> | ||
| </Card> | ||
| ); | ||
| }; | ||
|
|
||
| export default ChatWindow; | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,23 @@ | ||||||
| import React from 'react'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]: Let's try remove this import |
||||||
| import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; | ||||||
|
|
||||||
| import ChatWindow from './ChatWindow'; | ||||||
|
|
||||||
| import messages from './messages'; | ||||||
| import './MentoringTab.scss'; | ||||||
|
|
||||||
| const MentoringTab = ({ intl }) => ( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]: Let's add a little support message about what AI Mentoring is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an additional support option, we can add a paragon product tour to quickly introduce the user to the mentoring application. What do you think? |
||||||
| <div className="mx-auto mb-5 course-mentoring-tab"> | ||||||
| <h2 aria-level="1" className="h2 my-3"> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit]: I wonder if it is necessary to change I looked, on other tabs (Progress, Dates) a similar aria attribute is added in case of using another tag, different from the heading ( Let's change this a
Suggested change
|
||||||
| {intl.formatMessage(messages.title)} | ||||||
| </h2> | ||||||
|
|
||||||
| <ChatWindow /> | ||||||
| </div> | ||||||
| ); | ||||||
|
|
||||||
| MentoringTab.propTypes = { | ||||||
| intl: intlShape.isRequired, | ||||||
| }; | ||||||
|
|
||||||
| export default injectIntl(MentoringTab); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| .course-mentoring-tab { | ||
| max-width: 70rem; | ||
|
|
||
| .chat-window-wrapper { | ||
| height: 35rem; | ||
| overflow-y: auto; | ||
| } | ||
|
|
||
| .chat-message { | ||
| max-width: 65%; | ||
| } | ||
|
|
||
| .pgn__stateful-btn-state-pending { | ||
| opacity: 0.65 !important; | ||
| } | ||
| } | ||
|
|
||
| .fade-in { | ||
| animation: fadeInUp 0.3s ease-in; | ||
| } | ||
|
|
||
| @keyframes fadeInUp { | ||
| from { | ||
| opacity: 0; | ||
| transform: translateY(8px); | ||
| } | ||
| to { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| export const CHAT_STATUSES_MAP = { | ||
| default: 'default', | ||
| pending: 'pending', | ||
| error: 'error', | ||
| }; | ||
|
|
||
| export const CHAT_SENDERS = { | ||
| student: 'student', | ||
| ai: 'ai', | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as useWebSocket } from './useWebSocket'; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||
| import { useEffect, useRef } from 'react'; | ||||||
|
|
||||||
| /** | ||||||
| * Custom hook to manage a WebSocket connection. | ||||||
| * | ||||||
| * @param {string} url - The WebSocket server URL to connect to. | ||||||
| * @param {function} onMessage - Callback function to handle incoming messages. | ||||||
| * @param {function} onConnect - Callback function to handle success connection. | ||||||
| * @param {function} onError - Callback function to handle errors. | ||||||
| * @returns {Object} - An object containing a function `sendMessage` | ||||||
| * to send messages through the WebSocket connection. | ||||||
| */ | ||||||
| function useWebSocket({ | ||||||
| url, onMessage, onError, onConnect, | ||||||
| }) { | ||||||
| const socket = useRef(null); | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| socket.current = new WebSocket(url); | ||||||
| socket.current.onmessage = (e) => { | ||||||
| try { | ||||||
| const data = JSON.parse(e.data); | ||||||
| onMessage(data); | ||||||
| } catch (err) { | ||||||
| onMessage({ text: e.data }); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]: Should we display an error message in the |
||||||
| } | ||||||
| }; | ||||||
| socket.current.onerror = onError; | ||||||
| socket.current.onopen = onConnect; | ||||||
| return () => socket.current?.close(); | ||||||
| }, [url]); | ||||||
|
|
||||||
| const sendMessage = (msg) => { | ||||||
| if (socket.current.readyState === WebSocket.OPEN) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]: Let's additionally check for the presence of
Suggested change
|
||||||
| socket.current.send(JSON.stringify(msg)); | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| return { sendMessage }; | ||||||
| } | ||||||
|
|
||||||
| export default useWebSocket; | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import MentoringTab from './MentoringTab'; | ||
|
|
||
| export default MentoringTab; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||
| import { defineMessages } from '@edx/frontend-platform/i18n'; | ||||||
|
|
||||||
| const messages = defineMessages({ | ||||||
| title: { | ||||||
| id: 'learning.mentoring.title', | ||||||
| defaultMessage: 'Mentoring Assistant', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]: Similar to
Suggested change
|
||||||
| description: 'The title of mentoring tab (course timeline).', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| }, | ||||||
| thinking: { | ||||||
| id: 'learning.mentoring.message.thinking', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [optional]:
Suggested change
|
||||||
| defaultMessage: 'Thinking...', | ||||||
| description: 'Shown while the Chat is processing the user message', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| }, | ||||||
| placeholder: { | ||||||
| id: 'learning.mentoring.input.placeholder', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| defaultMessage: 'Type your message', | ||||||
| description: 'Placeholder text inside the chat input field', | ||||||
| }, | ||||||
| send: { | ||||||
| id: 'learning.mentoring.button.send', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| defaultMessage: 'Send', | ||||||
| description: 'Label for the send button in its default state', | ||||||
| }, | ||||||
| awaiting: { | ||||||
| id: 'learning.mentoring.button.awaiting', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| defaultMessage: 'Awaiting reply...', | ||||||
| description: 'Label for the send button while waiting for chat reply', | ||||||
| }, | ||||||
| error: { | ||||||
| id: 'learning.mentoring.button.error', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| defaultMessage: 'Error', | ||||||
| description: 'Label for the send button when an error occurs', | ||||||
| }, | ||||||
| aiSocketDomainMissing: { | ||||||
| id: 'learning.mentoring.error.missingSocketDomain', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| defaultMessage: 'Configuration Error: OPENEDX_AI_SOCKET_DOMAIN is not defined. ' | ||||||
| + 'Please set the WebSocket domain in your environment settings to enable AI communication.', | ||||||
|
Comment on lines
+36
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question]: The text of this message is quite technical, I would change it to a more student-friendly text, and in the console I would output a more technical error message for developers. What do you think? |
||||||
| description: 'Error shown when the WebSocket domain is not provided in the configuration', | ||||||
| }, | ||||||
| }); | ||||||
|
|
||||||
| export default messages; | ||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -8,7 +8,7 @@ import ReactDOM from 'react-dom'; | |||||
| import { Routes, Route } from 'react-router-dom'; | ||||||
|
|
||||||
| import { Helmet } from 'react-helmet'; | ||||||
| import { fetchDiscussionTab, fetchLiveTab } from './course-home/data/thunks'; | ||||||
| import { fetchDiscussionTab, fetchLiveTab, fetchMentoringTab } from './course-home/data/thunks'; | ||||||
| import DiscussionTab from './course-home/discussion-tab/DiscussionTab'; | ||||||
|
|
||||||
| import messages from './i18n'; | ||||||
|
|
@@ -20,6 +20,7 @@ import { CourseExit } from './courseware/course/course-exit'; | |||||
| import CoursewareContainer from './courseware'; | ||||||
| import CoursewareRedirectLandingPage from './courseware/CoursewareRedirectLandingPage'; | ||||||
| import DatesTab from './course-home/dates-tab'; | ||||||
| import MentoringTab from './course-home/mentoring-tab'; | ||||||
| import GoalUnsubscribe from './course-home/goal-unsubscribe'; | ||||||
| import ProgressTab from './course-home/progress-tab/ProgressTab'; | ||||||
| import { TabContainer } from './tab-page'; | ||||||
|
|
@@ -84,6 +85,16 @@ subscribe(APP_READY, () => { | |||||
| </DecodePageRoute> | ||||||
| )} | ||||||
| /> | ||||||
| <Route | ||||||
| path={DECODE_ROUTES.MENTORING} | ||||||
| element={( | ||||||
| <DecodePageRoute> | ||||||
| <TabContainer tab="mentoring" fetch={fetchMentoringTab} slice="courseHome"> | ||||||
| <MentoringTab /> | ||||||
| </TabContainer> | ||||||
| </DecodePageRoute> | ||||||
| )} | ||||||
| /> | ||||||
| <Route | ||||||
| path={DECODE_ROUTES.DISCUSSION} | ||||||
| element={( | ||||||
|
|
@@ -176,6 +187,7 @@ initialize({ | |||||
| PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null, | ||||||
| SHOW_UNGRADED_ASSIGNMENT_PROGRESS: process.env.SHOW_UNGRADED_ASSIGNMENT_PROGRESS || false, | ||||||
| ENABLE_XPERT_AUDIT: process.env.ENABLE_XPERT_AUDIT || false, | ||||||
| OPENEDX_AI_SOCKET_DOMAIN: process.env.OPENEDX_AI_SOCKET_DOMAIN || '', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question]: Should there be a null here, similar to other config variables?
Suggested change
|
||||||
| }, 'LearnerAppConfig'); | ||||||
| }, | ||||||
| }, | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[question]: Have you tried to test this functionality on different courses?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would also be useful to understand what limitations we have related to the course size + how the provision of course context works if it is empty.