Skip to content

Commit 471e5c1

Browse files
Syllabus chatbot UI (#1999)
* rename a few things * remove a few unused flex properties * simplify conditional * remove unneessary alignItems * only affect direct children * remove rawAnchor * drawer chat first pass * connect chat to api * show at md width * tweak spacing, update smoot * fix tests * add two simple tests * tweak title, prompts * bump smoot * bump smoot * bump smoot * bump smoot * rename css class * tweak demo height * bump smoot * use default collection_name * restore feature flag * fix a width issue * fix comment * bump smoot * bump smoot last time (??) * keep CallToAction border when chat open * equalize spacing left/right of chat * kebab-case-the-flag-like-others
1 parent 46a3898 commit 471e5c1

23 files changed

+531
-175
lines changed

frontends/.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ module.exports = {
9292
"**/*.test.tsx",
9393
"**/src/setupJest.ts",
9494
"**/jest-shared-setup.ts",
95+
"**/jsdom-extended.ts",
9596
"**/test-utils/**",
9697
"**/test-utils/**",
9798
"**/webpack.config.js",

frontends/jest.jsdom.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Config } from "@jest/types"
77
const config: Config.InitialOptions &
88
Pick<Required<Config.InitialOptions>, "setupFilesAfterEnv"> = {
99
setupFilesAfterEnv: [resolve(__dirname, "./jest-shared-setup.ts")],
10-
testEnvironment: "jsdom",
10+
testEnvironment: resolve(__dirname, "./jsdom-extended.ts"),
1111
transform: {
1212
"^.+\\.(t|j)sx?$": "@swc/jest",
1313
},

frontends/jsdom-extended.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* For some reason Jest's JSDOM environment does not include these, though they
3+
* have been available in NodeJS and web browsers for a while now.
4+
*/
5+
import { TestEnvironment } from "jest-environment-jsdom"
6+
import { EnvironmentContext, JestEnvironmentConfig } from "@jest/environment"
7+
8+
class JSDOMEnvironmentExtended extends TestEnvironment {
9+
constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {
10+
super(config, context)
11+
12+
this.global.TransformStream = TransformStream
13+
this.global.ReadableStream = ReadableStream
14+
this.global.Response = Response
15+
this.global.TextDecoderStream = TextDecoderStream
16+
}
17+
}
18+
19+
export default JSDOMEnvironmentExtended

frontends/main/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const config: Config.InitialOptions = {
55
...baseConfig,
66
setupFilesAfterEnv: [
77
...baseConfig.setupFilesAfterEnv,
8-
"./test-utils/setupJest.ts",
8+
"./test-utils/setupJest.tsx",
99
],
1010
moduleNameMapper: {
1111
...baseConfig.moduleNameMapper,

frontends/main/next.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ const nextConfig = {
7878
]
7979
},
8080

81+
transpilePackages: ["@mitodl/smoot-design/ai"],
82+
8183
images: {
8284
remotePatterns: [
8385
{

frontends/main/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
"@emotion/cache": "^11.13.1",
1515
"@emotion/styled": "^11.11.0",
1616
"@mitodl/course-search-utils": "3.3.2",
17-
"@mitodl/smoot-design": "^2.0.1",
17+
"@mitodl/smoot-design": "^3.0.1",
1818
"@next/bundle-analyzer": "^14.2.15",
1919
"@nlux/react": "^2.17.1",
2020
"@nlux/themes": "^2.17.1",
2121
"@remixicon/react": "^4.2.0",
2222
"@sentry/nextjs": "^8.36.0",
2323
"@tanstack/react-query": "^4.36.1",
2424
"api": "workspace:*",
25+
"classnames": "^2.5.1",
2526
"formik": "^2.4.6",
2627
"iso-639-1": "^3.1.4",
2728
"lodash": "^4.17.21",
@@ -54,6 +55,7 @@
5455
"http-proxy-middleware": "^3.0.0",
5556
"jest": "^29.7.0",
5657
"jest-extended": "^4.0.2",
58+
"jest-next-dynamic-ts": "^0.1.1",
5759
"ol-test-utilities": "0.0.0",
5860
"ts-jest": "^29.2.4",
5961
"typescript": "^5"

frontends/main/src/app-pages/ChatSyllabusPage/ChatSyllabusPage.tsx

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ import StyledContainer from "@/page-components/StyledContainer/StyledContainer"
99
import { InputLabel, Select } from "@mui/material"
1010
import { AiChat, AiChatProps } from "@mitodl/smoot-design/ai"
1111
import { extractJSONFromComment } from "ol-utilities"
12-
13-
function getCookie(name: string) {
14-
const value = `; ${document.cookie}`
15-
const parts = value.split(`; ${name}=`)
16-
if (parts.length === 2) {
17-
return parts.pop()?.split(";").shift()
18-
}
19-
}
12+
import { getCsrfToken } from "@/common/utils"
2013

2114
const STARTERS: AiChatProps["conversationStarters"] = [
2215
{ content: "What are the prerequisites for this course?" },
@@ -47,24 +40,16 @@ const StyledDebugPre = styled.pre({
4740
width: "80%",
4841
whiteSpace: "pre-wrap",
4942
})
43+
const AiChatStyled = styled(AiChat)({
44+
height: "60vh",
45+
})
5046

5147
const ChatSyllabusPage = () => {
5248
const botEnabled = useFeatureFlagEnabled(FeatureFlags.RecommendationBot)
5349
const [readableId, setReadableId] = useState("18.06SC+fall_2011")
5450
const [collectionName, setCollectionName] = useState("content_files")
5551
const [debugInfo, setDebugInfo] = useState("")
5652

57-
const parseContent = (content: string | unknown) => {
58-
if (typeof content !== "string") {
59-
return ""
60-
}
61-
const contentParts = content.split("<!--")
62-
if (contentParts.length > 1) {
63-
setDebugInfo(contentParts[1])
64-
}
65-
return contentParts[0]
66-
}
67-
6853
return (
6954
<StyledContainer>
7055
{
@@ -121,17 +106,16 @@ const ChatSyllabusPage = () => {
121106
</div>
122107
</FormContainer>
123108
</form>
124-
<AiChat
109+
<AiChatStyled
110+
title="Syllabus Chatbot"
125111
initialMessages={INITIAL_MESSAGES}
126112
conversationStarters={STARTERS}
127-
parseContent={parseContent}
128113
requestOpts={{
129114
apiUrl: `${process.env.NEXT_PUBLIC_MITOL_API_BASE_URL}/api/v0/syllabus_agent/`,
130-
headersOpts: {
131-
"X-CSRFToken":
132-
getCookie(
133-
process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME || "csrftoken",
134-
) ?? "",
115+
fetchOpts: {
116+
headers: {
117+
"X-CSRFToken": getCsrfToken(),
118+
},
135119
},
136120
transformBody: (messages) => {
137121
return {
@@ -140,6 +124,12 @@ const ChatSyllabusPage = () => {
140124
collection_name: collectionName,
141125
}
142126
},
127+
onFinish: (message) => {
128+
const contentParts = message.content.split("<!--")
129+
if (contentParts.length > 1) {
130+
setDebugInfo(contentParts[1])
131+
}
132+
},
143133
}}
144134
/>
145135
{debugInfo &&

frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ const AUTH_TEXT_DATA = {
7676
text: "As a member, get personalized recommendations, curate learning lists, and follow your areas of interest.",
7777
linkProps: {
7878
children: "Sign Up for Free",
79-
rawAnchor: true,
8079
href: urls.login({ pathname: urls.DASHBOARD_HOME }),
8180
},
8281
},

frontends/main/src/common/feature_flags.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
export enum FeatureFlags {
55
EnableEcommerce = "enable-ecommerce",
6-
DrawerV2Enabled = "lr_drawer_v2",
6+
LrDrawerChatbot = "lr-drawer-chatbot",
77
RecommendationBot = "recommendation-bot",
88
}

frontends/main/src/common/utils.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,25 @@ const aggregateCourseCounts = (
2929
)
3030
}
3131

32-
export { getSearchParamMap, aggregateProgramCounts, aggregateCourseCounts }
32+
function getCookie(name: string) {
33+
const value = `; ${document.cookie}`
34+
const parts = value.split(`; ${name}=`)
35+
if (parts.length === 2) {
36+
return parts.pop()?.split(";").shift()
37+
}
38+
}
39+
/**
40+
* Returns CsrfToken from cookie if it is present
41+
*/
42+
const getCsrfToken = () => {
43+
return (
44+
getCookie(process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME || "csrftoken") ?? ""
45+
)
46+
}
47+
48+
export {
49+
getSearchParamMap,
50+
aggregateProgramCounts,
51+
aggregateCourseCounts,
52+
getCsrfToken,
53+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from "react"
2+
import AiChatSyllabus from "./AiChatSyllabus"
3+
import { renderWithProviders, screen } from "@/test-utils"
4+
import { factories, setMockResponse, urls } from "api/test-utils"
5+
6+
/**
7+
* Note: This component is primarily tested in @mitodl/smoot-design.
8+
*
9+
* Here we just check a few config settings.
10+
*/
11+
describe("AiChatSyllabus", () => {
12+
test("Greets authenticated user by name", async () => {
13+
const resource = factories.learningResources.course()
14+
const user = factories.user.user()
15+
16+
// Sanity
17+
expect(user.profile.name).toBeTruthy()
18+
19+
setMockResponse.get(urls.userMe.get(), user)
20+
renderWithProviders(
21+
<AiChatSyllabus onClose={jest.fn()} resource={resource} />,
22+
)
23+
24+
// byAll because there are two instances, one is SR-only in an aria-live area
25+
// check for username and resource title
26+
await screen.findAllByText(
27+
new RegExp(`Hello ${user.profile.name}.*${resource.title}.*`),
28+
)
29+
})
30+
31+
test("Greets anonymous user generically", async () => {
32+
const resource = factories.learningResources.course()
33+
34+
setMockResponse.get(urls.userMe.get(), {}, { code: 403 })
35+
renderWithProviders(
36+
<AiChatSyllabus onClose={jest.fn()} resource={resource} />,
37+
)
38+
39+
// byAll because there are two instances, one is SR-only in an aria-live area
40+
// check for username and resource title
41+
await screen.findAllByText(new RegExp(`Hello and.*${resource.title}.*`))
42+
})
43+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as React from "react"
2+
import type { AiChatProps } from "@mitodl/smoot-design/ai"
3+
import { getCsrfToken } from "@/common/utils"
4+
import { LearningResource } from "api"
5+
import { useUserMe } from "api/hooks/user"
6+
import type { User } from "api/hooks/user"
7+
import dynamic from "next/dynamic"
8+
import type { SyllabusChatRequestRequest } from "api/v0"
9+
10+
const AiChat = dynamic(
11+
() => import("@mitodl/smoot-design/ai").then((mod) => mod.AiChat),
12+
{ ssr: false },
13+
)
14+
15+
const STARTERS: AiChatProps["conversationStarters"] = [
16+
{ content: "What is this course about?" },
17+
{ content: "What are the prerequisites for this course?" },
18+
{ content: "How will this course be graded?" },
19+
]
20+
21+
const getInitialMessage = (
22+
resource: LearningResource,
23+
user?: User,
24+
): AiChatProps["initialMessages"] => {
25+
const grettings = user?.profile?.name
26+
? `Hello ${user.profile.name}, `
27+
: "Hello and "
28+
return [
29+
{
30+
content: `${grettings} welcome to **${resource.title}**. How can I assist you today?`,
31+
role: "assistant",
32+
},
33+
]
34+
}
35+
36+
type AiChatSyllabusProps = {
37+
onClose: () => void
38+
resource?: LearningResource
39+
className?: string
40+
}
41+
42+
const AiChatSyllabus: React.FC<AiChatSyllabusProps> = ({
43+
onClose,
44+
resource,
45+
...props
46+
}) => {
47+
const user = useUserMe()
48+
if (!resource) return null
49+
50+
return (
51+
<AiChat
52+
data-testid="ai-chat-syllabus"
53+
conversationStarters={STARTERS}
54+
initialMessages={getInitialMessage(resource, user.data)}
55+
chatId={`chat-${resource?.readable_id}`}
56+
title="Ask Tim about this course"
57+
onClose={onClose}
58+
requestOpts={{
59+
apiUrl: `${process.env.NEXT_PUBLIC_MITOL_API_BASE_URL}/api/v0/syllabus_agent/`,
60+
fetchOpts: {
61+
headers: {
62+
"X-CSRFToken": getCsrfToken(),
63+
},
64+
},
65+
transformBody: (messages) => {
66+
const body: SyllabusChatRequestRequest = {
67+
collection_name: "content_files",
68+
message: messages[messages.length - 1].content,
69+
readable_id: resource?.readable_id,
70+
}
71+
return body
72+
},
73+
}}
74+
{...props}
75+
/>
76+
)
77+
}
78+
79+
export default AiChatSyllabus

frontends/main/src/page-components/LearningResourceDrawer/LearningResourceDrawer.test.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
1313
import { LearningResource, ResourceTypeEnum } from "api"
1414
import { makeUserSettings } from "@/test-utils/factories"
1515
import type { User } from "api/hooks/user"
16+
import { usePostHog } from "posthog-js/react"
1617

1718
jest.mock("./LearningResourceExpanded", () => {
1819
const actual = jest.requireActual("./LearningResourceExpanded")
@@ -23,16 +24,11 @@ jest.mock("./LearningResourceExpanded", () => {
2324
})
2425

2526
const mockedPostHogCapture = jest.fn()
26-
27-
jest.mock("posthog-js/react", () => ({
28-
PostHogProvider: (props: { children: React.ReactNode }) => (
29-
<div data-testid="phProvider">{props.children}</div>
30-
),
31-
32-
usePostHog: () => {
33-
return { capture: mockedPostHogCapture }
34-
},
35-
}))
27+
jest.mock("posthog-js/react")
28+
jest.mocked(usePostHog).mockReturnValue(
29+
// @ts-expect-error Not mocking all of posthog
30+
{ capture: mockedPostHogCapture },
31+
)
3632

3733
describe("LearningResourceDrawer", () => {
3834
const setupApis = (

0 commit comments

Comments
 (0)