Skip to content

Commit 008358b

Browse files
fix: Math \boldsymbol is not rendered (#184)
Co-authored-by: Chris Chudzicki <[email protected]>
1 parent ac6b16c commit 008358b

File tree

8 files changed

+358
-62
lines changed

8 files changed

+358
-62
lines changed

src/bundles/AiDrawer/AiDrawerManager.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from "react"
22
import { useEffect, useState } from "react"
33
import { AiDrawer } from "./AiDrawer"
44
import type { AiDrawerProps, AiDrawerSettings } from "./AiDrawer"
5+
import { contentHash } from "../../utils/string"
56

67
type AiDrawerInitMessage = {
78
type: "smoot-design::ai-drawer-open" | "smoot-design::tutor-drawer-open" // ("smoot-design::tutor-drawer-open" is legacy)
@@ -16,12 +17,7 @@ type AiDrawerInitMessage = {
1617

1718
const hashPayload = (payload: AiDrawerInitMessage["payload"]) => {
1819
const str = JSON.stringify(payload)
19-
let hash = 5381
20-
for (let i = 0; i < str.length; i++) {
21-
hash = (hash << 5) + hash + str.charCodeAt(i)
22-
hash = hash & hash
23-
}
24-
return Math.abs(hash).toString(36)
20+
return contentHash(str)
2521
}
2622

2723
type AiDrawerManagerProps = {

src/components/AiChat/AiChat.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
/* eslint-disable testing-library/await-async-utils */
33
import { render, screen, waitFor } from "@testing-library/react"
44
import user from "@testing-library/user-event"
5-
import { AiChat, replaceMathjax } from "./AiChat"
5+
import { AiChat } from "./AiChat"
6+
import { replaceMathjax } from "./Markdown"
67
import { ThemeProvider } from "../ThemeProvider/ThemeProvider"
78
import * as React from "react"
89
import { AiChatProps } from "./types"

src/components/AiChat/AiChat.tsx

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,37 @@ import EllipsisIcon from "./EllipsisIcon"
2121
import { SimpleSelectField } from "../SimpleSelect/SimpleSelect"
2222
import { useFetch } from "./utils"
2323
import { SelectChangeEvent } from "@mui/material/Select"
24+
import type { MathJax3Config } from "better-react-mathjax"
2425
import { MathJaxContext } from "better-react-mathjax"
26+
import deepmerge from "@mui/utils/deepmerge"
2527

2628
const ConditionalMathJaxWrapper: React.FC<{
2729
useMathJax: boolean
30+
config?: MathJax3Config
2831
children: React.ReactNode
29-
}> = ({ useMathJax, children }) => {
32+
}> = ({ useMathJax, config = {}, children }) => {
3033
if (!useMathJax) {
3134
return <>{children}</>
3235
}
33-
return <MathJaxContext>{children}</MathJaxContext>
36+
37+
return (
38+
<MathJaxContext
39+
config={deepmerge(
40+
{
41+
startup: {
42+
typeset: false,
43+
},
44+
loader: { load: ["[tex]/boldsymbol"] },
45+
tex: {
46+
packages: { "[+]": ["boldsymbol"] },
47+
},
48+
},
49+
config,
50+
)}
51+
>
52+
{children}
53+
</MathJaxContext>
54+
)
3455
}
3556

3657
const classes = {
@@ -251,13 +272,13 @@ const AiChatDisplay: FC<AiChatDisplayProps> = ({
251272
scrollElement,
252273
ref,
253274
useMathJax = false,
275+
mathJaxConfig,
254276
onSubmit,
255277
problemSetListUrl,
256278
problemSetInitialMessages,
257279
problemSetEmptyMessages,
258280
...others // Could contain data attributes
259281
}) => {
260-
const containerRef = useRef<HTMLDivElement>(null)
261282
const messagesContainerRef = useRef<HTMLDivElement>(null)
262283
const chatScreenRef = useRef<HTMLDivElement>(null)
263284
const promptInputRef = useRef<HTMLDivElement>(null)
@@ -291,7 +312,9 @@ const AiChatDisplay: FC<AiChatDisplayProps> = ({
291312
const [showEntryScreen, setShowEntryScreen] = useState(entryScreenEnabled)
292313
useEffect(() => {
293314
if (!showEntryScreen) {
294-
promptInputRef.current?.querySelector("input")?.focus()
315+
promptInputRef.current
316+
?.querySelector("input")
317+
?.focus({ preventScroll: true })
295318
}
296319
}, [showEntryScreen])
297320

@@ -318,7 +341,7 @@ const AiChatDisplay: FC<AiChatDisplayProps> = ({
318341
])
319342
}
320343
}
321-
}, [problemSetListResponse])
344+
}, [problemSetListResponse, problemSetEmptyMessages, setMessages])
322345

323346
useEffect(() => {
324347
if (
@@ -366,7 +389,7 @@ const AiChatDisplay: FC<AiChatDisplayProps> = ({
366389
const externalScroll = !!scrollElement
367390

368391
return (
369-
<Container className={className} ref={containerRef}>
392+
<Container className={className}>
370393
{showEntryScreen ? (
371394
<EntryScreen
372395
className={classes.entryScreenContainer}
@@ -420,41 +443,37 @@ const AiChatDisplay: FC<AiChatDisplayProps> = ({
420443
) : null
421444
}
422445
/>
423-
<ConditionalMathJaxWrapper useMathJax={useMathJax}>
446+
<ConditionalMathJaxWrapper
447+
useMathJax={useMathJax}
448+
config={mathJaxConfig}
449+
>
424450
<MessagesContainer
425451
className={classes.messagesContainer}
426452
externalScroll={externalScroll}
427453
ref={messagesContainerRef}
428454
>
429-
{messages.map((m: Message, i) => {
430-
// Our Markdown+Mathjax has issues when rendering streaming display math
431-
// Force a re-render of the last (streaming) message when it's done loading.
432-
const key =
433-
i === messages.length - 1 && isLoading
434-
? `isLoading-${m.id}`
435-
: m.id
455+
{messages.map((message: Message, index: number) => {
436456
return (
437457
<MessageRow
438-
key={key}
439-
data-chat-role={m.role}
458+
key={index}
459+
data-chat-role={message.role}
440460
className={classNames(classes.messageRow, {
441-
[classes.messageRowUser]: m.role === "user",
442-
[classes.messageRowAssistant]: m.role === "assistant",
461+
[classes.messageRowUser]: message.role === "user",
462+
[classes.messageRowAssistant]:
463+
message.role === "assistant",
443464
})}
444465
>
445466
<Message className={classes.message}>
446-
<VisuallyHidden as={m.role === "user" ? "h5" : "h6"}>
447-
{m.role === "user"
467+
<VisuallyHidden
468+
as={message.role === "user" ? "h5" : "h6"}
469+
>
470+
{message.role === "user"
448471
? "You said: "
449472
: "Assistant said: "}
450473
</VisuallyHidden>
451-
{useMathJax ? (
452-
<Markdown enableMathjax={true}>
453-
{replaceMathjax(m.content)}
454-
</Markdown>
455-
) : (
456-
<Markdown>{m.content}</Markdown>
457-
)}
474+
<Markdown useMathJax={useMathJax}>
475+
{message.content}
476+
</Markdown>
458477
</Message>
459478
</MessageRow>
460479
)
@@ -583,21 +602,4 @@ const AiChat: FC<AiChatProps> = ({
583602
)
584603
}
585604

586-
// react-markdown expects Mathjax delimiters to be $...$ or $$...$$
587-
// the prompt for the tutorbot asks for Mathjax tags with $ format but
588-
// the LLM does not get it right all the time
589-
// this function replaces the Mathjax tags with the correct format
590-
// eventually we will probably be able to remove this as LLMs get better
591-
function replaceMathjax(inputString: string): string {
592-
// Replace instances of \(...\) and \[...\] Mathjax tags with $...$
593-
// and $$...$$ tags.
594-
const INLINE_MATH_REGEX = /\\\((.*?)\\\)/g
595-
const DISPLAY_MATH_REGEX = /\\\[(([\s\S]*?))\\\]/g
596-
inputString = inputString.replace(
597-
INLINE_MATH_REGEX,
598-
(_match, p1) => `$${p1}$`,
599-
)
600-
return inputString.replace(DISPLAY_MATH_REGEX, (_match, p1) => `$$${p1}$$`)
601-
}
602-
603-
export { AiChatDisplay, AiChat, replaceMathjax }
605+
export { AiChatDisplay, AiChat }

src/components/AiChat/AiChatMarkdown.stories.tsx

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import styled from "@emotion/styled"
55
import { handlers } from "./test-utils/api"
66

77
const TEST_API_STREAMING = "http://localhost:4567/streaming"
8+
const TEST_API_STREAMING_MATH = "http://localhost:4567/streaming-math"
89

910
const Container = styled.div({
1011
width: "100%",
11-
height: "500px",
1212
})
1313

1414
const meta: Meta<typeof AiChat> = {
@@ -20,7 +20,7 @@ const meta: Meta<typeof AiChat> = {
2020
render: (args) => <AiChat {...args} />,
2121
decorators: (Story, context) => {
2222
return (
23-
<Container>
23+
<Container style={{ height: context.parameters.height || "500px" }}>
2424
<Story key={String(context.args.entryScreenEnabled)} />
2525
</Container>
2626
)
@@ -122,6 +122,12 @@ def f(x):
122122
},
123123
}
124124

125+
/**
126+
* Shows MathJax rendering of inline and block math.
127+
*
128+
* We set `startup.typeset` to false in the MathJax config as by default MathJax will typeset math anywhere on the page and outside of our components - the following should not appear typeset:
129+
* \\(x = \\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}\\)
130+
*/
125131
export const Math: Story = {
126132
args: {
127133
requestOpts: { apiUrl: TEST_API_STREAMING },
@@ -139,13 +145,97 @@ $$
139145
x = \\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}
140146
$$
141147
148+
Math is rendered using MathJax only if the \`useMathJax\` prop is set to true.
149+
`,
150+
},
151+
],
152+
useMathJax: true,
153+
},
154+
}
155+
156+
/**
157+
* Ensures that LaTeX delimiters `\\(...\\)` and `\\[...\\]` are rendered correctly.
158+
*
159+
* better-react-mathjax expects TeX delimiters `$...$` or `$$...$$`, though the LLMs may produce LaTeX delimiters despite instruction.
160+
*
161+
*/
162+
export const MathLatexDelimiters: Story = {
163+
args: {
164+
requestOpts: { apiUrl: TEST_API_STREAMING },
165+
entryScreenEnabled: false,
166+
conversationStarters: [],
167+
initialMessages: [
168+
{
169+
role: "assistant",
170+
content: `Some inline math: \\(x = \\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}\\)
171+
172+
And some block math:
173+
174+
\\[
175+
x = \\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}
176+
\\]
177+
142178
Math is rendered using MathJax only if the \`useMathJax\` prop is set to true.`,
143179
},
144180
],
145181
useMathJax: true,
146182
},
147183
}
148184

185+
/**
186+
* > **⚠ NOTE:** Although the `boldsymbol` package is lazily loaded by default,
187+
* > we have found that on slow networks a race condition can occur resulting in
188+
* > un-rendered boldsymbol commands. This behavior can be reproduced by removing
189+
* > the `loader` configuration that explicitly loads the `boldsymbol` package
190+
* > and using a throttled network connection.
191+
*
192+
* This story demonstrates math rendering of LaTeX commands from specialized
193+
* packages. MathJax includes some packages in its base configuration, loads
194+
* some packages lazily on demand ("autoloads"), and requires some packages to
195+
* be explicitly loaded.
196+
*
197+
* The responses here demonstrate commands from the following packages:
198+
*
199+
* | Package | Included by default? | Lazy loaded by default? |
200+
* |------------|----------------------|-------------------------|
201+
* | `ams` | yes | n/a |
202+
* | `boldsymbol` | no | yes |
203+
* | `physics` | no | no |
204+
*
205+
* See [https://docs.mathjax.org/en/latest/input/tex/macros/index.html](https://docs.mathjax.org/en/latest/input/tex/macros/index.html)
206+
*
207+
* Therefore, we explicitly include boldsymbol in the `loader` configuration
208+
* to force it to be loaded up front.
209+
*
210+
* ```tsx
211+
* mathJaxConfig: {
212+
* loader: { load: ["[tex]/physics"] },
213+
* tex: { packages: { "[+]": ["physics"] } },
214+
* }
215+
* ```
216+
*
217+
* Note that multiple \<MathJaxContext\> instances on the page share a single state. If they have different configs, only the first is applied.
218+
*
219+
*/
220+
export const MathWithExtensionPackages: Story = {
221+
parameters: {
222+
height: "800px",
223+
},
224+
args: {
225+
requestOpts: { apiUrl: TEST_API_STREAMING_MATH },
226+
entryScreenEnabled: false,
227+
conversationStarters: [],
228+
initialMessages: [
229+
{
230+
role: "assistant",
231+
content:
232+
"Ask me something and I'll respond with math that includes TeX symbols that are not in the base MathJax package",
233+
},
234+
],
235+
useMathJax: true,
236+
},
237+
}
238+
149239
export const SimpleOrderedList: Story = {
150240
args: {
151241
requestOpts: { apiUrl: TEST_API_STREAMING },

0 commit comments

Comments
 (0)