Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
OPENEDX_AI_SOCKET_DOMAIN=''
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
OPENEDX_AI_SOCKET_DOMAIN='127.0.0.1:8002'
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
OPENEDX_AI_SOCKET_DOMAIN=''
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const DECODE_ROUTES = {
HOME: '/course/:courseId/home',
LIVE: '/course/:courseId/live',
DATES: '/course/:courseId/dates',
MENTORING: '/course/:courseId/mentoring',
DISCUSSION: '/course/:courseId/discussion/:path/*',
PROGRESS: [
'/course/:courseId/progress/:targetUserId/',
Expand Down
4 changes: 4 additions & 0 deletions src/course-home/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export function fetchDiscussionTab(courseId) {
return fetchTab(courseId, 'discussion');
}

export function fetchMentoringTab(courseId) {
return fetchTab(courseId, 'mentoring');
}

export function dismissWelcomeMessage(courseId) {
return async () => postDismissWelcomeMessage(courseId);
}
Expand Down
141 changes: 141 additions & 0 deletions src/course-home/mentoring-tab/ChatWindow.jsx
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}/`,

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?

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.

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;
23 changes: 23 additions & 0 deletions src/course-home/mentoring-tab/MentoringTab.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';

Choose a reason for hiding this comment

The 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 }) => (

Choose a reason for hiding this comment

The 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

Choose a reason for hiding this comment

The 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">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]: I wonder if it is necessary to change aria-level="1" for the <h2> heading? By default, all headers have aria-level equal to the header level (<h2> - aria-level="2"), in this case you increase aria-level (<h2> - aria-level="1").

I looked, on other tabs (Progress, Dates) a similar aria attribute is added in case of using another tag, different from the heading (<div role="heading" aria-level="1" class="h2 my-3">Important dates</div>).

Let's change this a <h1> level heading and remove aria-level

Suggested change
<h2 aria-level="1" className="h2 my-3">
<h1 className="my-3">

{intl.formatMessage(messages.title)}
</h2>

<ChatWindow />
</div>
);

MentoringTab.propTypes = {
intl: intlShape.isRequired,
};

export default injectIntl(MentoringTab);
31 changes: 31 additions & 0 deletions src/course-home/mentoring-tab/MentoringTab.scss
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);
}
}
10 changes: 10 additions & 0 deletions src/course-home/mentoring-tab/constants.js
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',
};
1 change: 1 addition & 0 deletions src/course-home/mentoring-tab/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useWebSocket } from './useWebSocket';
42 changes: 42 additions & 0 deletions src/course-home/mentoring-tab/hooks/useWebSocket.js
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 });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional]: Should we display an error message in the catch block?

}
};
socket.current.onerror = onError;
socket.current.onopen = onConnect;
return () => socket.current?.close();
}, [url]);

const sendMessage = (msg) => {
if (socket.current.readyState === WebSocket.OPEN) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional]: Let's additionally check for the presence of socket.current

Suggested change
if (socket.current.readyState === WebSocket.OPEN) {
if (socket.current && socket.current.readyState === WebSocket.OPEN) {

socket.current.send(JSON.stringify(msg));
}
};

return { sendMessage };
}

export default useWebSocket;
3 changes: 3 additions & 0 deletions src/course-home/mentoring-tab/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MentoringTab from './MentoringTab';

export default MentoringTab;
42 changes: 42 additions & 0 deletions src/course-home/mentoring-tab/messages.ts
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',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional]: Similar to Your progress (first letter is capital, rest are lowercase)

Suggested change
defaultMessage: 'Mentoring Assistant',
defaultMessage: 'Mentoring assistant',

description: 'The title of mentoring tab (course timeline).',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'The title of mentoring tab (course timeline).',
description: 'The title of mentoring tab (course outline).',

},
thinking: {
id: 'learning.mentoring.message.thinking',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional]:

Suggested change
id: 'learning.mentoring.message.thinking',
id: 'learning.mentoring.chat.message.thinking',

defaultMessage: 'Thinking...',
description: 'Shown while the Chat is processing the user message',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'Shown while the Chat is processing the user message',
description: 'Shown while the chat is processing the user message',

},
placeholder: {
id: 'learning.mentoring.input.placeholder',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'learning.mentoring.input.placeholder',
id: 'learning.mentoring.chat.input.placeholder',

defaultMessage: 'Type your message',
description: 'Placeholder text inside the chat input field',
},
send: {
id: 'learning.mentoring.button.send',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'learning.mentoring.button.send',
id: 'learning.mentoring.chat.button.send',

defaultMessage: 'Send',
description: 'Label for the send button in its default state',
},
awaiting: {
id: 'learning.mentoring.button.awaiting',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'learning.mentoring.button.awaiting',
id: 'learning.mentoring.chat.button.awaiting',

defaultMessage: 'Awaiting reply...',
description: 'Label for the send button while waiting for chat reply',
},
error: {
id: 'learning.mentoring.button.error',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'learning.mentoring.button.error',
id: 'learning.mentoring.chat.button.error',

defaultMessage: 'Error',
description: 'Label for the send button when an error occurs',
},
aiSocketDomainMissing: {
id: 'learning.mentoring.error.missingSocketDomain',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: 'learning.mentoring.error.missingSocketDomain',
id: 'learning.mentoring.chat.error.missingSocketDomain',

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

Choose a reason for hiding this comment

The 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;
14 changes: 13 additions & 1 deletion src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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={(
Expand Down Expand Up @@ -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 || '',

Choose a reason for hiding this comment

The 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
OPENEDX_AI_SOCKET_DOMAIN: process.env.OPENEDX_AI_SOCKET_DOMAIN || '',
OPENEDX_AI_SOCKET_DOMAIN: process.env.OPENEDX_AI_SOCKET_DOMAIN || null,

}, 'LearnerAppConfig');
},
},
Expand Down
Loading