Skip to content

Commit 532c457

Browse files
committed
Implement saving conversation as email
1 parent 3d8b19a commit 532c457

File tree

2 files changed

+223
-5
lines changed

2 files changed

+223
-5
lines changed

src/client/components/ChatV2/ChatV2.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import RestartAltIcon from '@mui/icons-material/RestartAlt'
2-
import EmailIcon from '@mui/icons-material/Email'
32
import HelpIcon from '@mui/icons-material/Help'
43
import SettingsIcon from '@mui/icons-material/Settings'
54
import { Alert, Box, Button, Drawer, Tooltip, Typography } from '@mui/material'
@@ -32,6 +31,7 @@ import Annotations from './Annotations'
3231
import { useIsEmbedded } from '../../contexts/EmbeddedContext'
3332
import { enqueueSnackbar } from 'notistack'
3433
import { useAnalyticsDispatch } from '../../stores/analytics'
34+
import EmailButton from './EmailButton'
3535

3636
function useLocalStorageStateWithURLDefault(key: string, defaultValue: string, urlKey: string) {
3737
const [value, setValue] = useLocalStorageState(key, defaultValue)
@@ -397,6 +397,7 @@ export const ChatV2 = () => {
397397
ragIndex={ragIndex}
398398
setRagIndexId={setRagIndexId}
399399
ragIndices={ragIndices}
400+
messages={messages}
400401
/>
401402
</Drawer>
402403
<LeftMenu
@@ -411,6 +412,7 @@ export const ChatV2 = () => {
411412
ragIndex={ragIndex}
412413
setRagIndexId={setRagIndexId}
413414
ragIndices={ragIndices}
415+
messages={messages}
414416
/>
415417
</>
416418
)}
@@ -535,7 +537,20 @@ export const ChatV2 = () => {
535537
)
536538
}
537539

538-
const LeftMenu = ({ sx, course, handleReset, user, t, setSettingsModalOpen, setDisclaimerStatus, showRagSelector, ragIndex, setRagIndexId, ragIndices }) => {
540+
const LeftMenu = ({
541+
sx,
542+
course,
543+
handleReset,
544+
user,
545+
t,
546+
setSettingsModalOpen,
547+
setDisclaimerStatus,
548+
showRagSelector,
549+
ragIndex,
550+
setRagIndexId,
551+
ragIndices,
552+
messages,
553+
}) => {
539554
return (
540555
<Box sx={sx}>
541556
<Box
@@ -562,9 +577,7 @@ const LeftMenu = ({ sx, course, handleReset, user, t, setSettingsModalOpen, setD
562577
arrow
563578
placement="right"
564579
>
565-
<OutlineButtonBlack startIcon={<EmailIcon />} onClick={() => alert('Not yet supported')}>
566-
{t('email:save')}
567-
</OutlineButtonBlack>
580+
<EmailButton messages={messages} disabled={!messages?.length} />
568581
</Tooltip>
569582
<OutlineButtonBlack startIcon={<SettingsIcon />} onClick={() => setSettingsModalOpen(true)} id="settings-button">
570583
{t('chat:settings')}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { Tooltip, Typography } from '@mui/material'
2+
import EmailIcon from '@mui/icons-material/Email'
3+
import ReactMarkdown from 'react-markdown'
4+
import remarkGfm from 'remark-gfm'
5+
import { renderToStaticMarkup } from 'react-dom/server'
6+
import { enqueueSnackbar } from 'notistack'
7+
import { useTranslation } from 'react-i18next'
8+
import useCurrentUser from '../../hooks/useCurrentUser'
9+
import type { Message } from '../../types'
10+
import { sendEmail } from '../../util/email'
11+
import { OutlineButtonBlack } from './generics/Buttons'
12+
13+
const escapeHtml = (str: string): string =>
14+
str.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
15+
16+
const formatEmailContent = (content: string): string => {
17+
// emails don't exactly support katex etc – hence this unicode mayhem T_T
18+
const emailFriendlyMath = content
19+
.replace(/\$\$([^$]+)\$\$/g, '[$1]')
20+
.replace(/\$([^$]+)\$/g, '$1')
21+
.replace(/\\text\{([^}]+)\}/g, '$1')
22+
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1)/($2)')
23+
.replace(/\\sqrt\{([^}]+)\}/g, '√($1)')
24+
.replace(/\\sum/g, 'Σ')
25+
.replace(/\\int/g, '∫')
26+
.replace(/\\infty/g, '∞')
27+
.replace(/\\alpha/g, 'α')
28+
.replace(/\\beta/g, 'β')
29+
.replace(/\\gamma/g, 'γ')
30+
.replace(/\\delta/g, 'δ')
31+
.replace(/\\pi/g, 'π')
32+
.replace(/\\theta/g, 'θ')
33+
.replace(/\\lambda/g, 'λ')
34+
.replace(/\\mu/g, 'μ')
35+
.replace(/\\sigma/g, 'σ')
36+
.replace(/\\phi/g, 'φ')
37+
.replace(/\\omega/g, 'ω')
38+
.replace(/\\leq/g, '≤')
39+
.replace(/\\geq/g, '≥')
40+
.replace(/\\neq/g, '≠')
41+
.replace(/\\approx/g, '≈')
42+
.replace(/\\pm/g, '±')
43+
.replace(/\\cdot/g, '·')
44+
.replace(/\\times/g, '×')
45+
.replace(/\\div/g, '÷')
46+
.replace(/\\abs\{([^}]+)\}/g, '|$1|')
47+
.replace(/\\norm\{([^}]+)\}/g, '||$1||')
48+
.replace(/\\R/g, 'ℝ')
49+
.replace(/\\C/g, 'ℂ')
50+
.replace(/\\N/g, 'ℕ')
51+
.replace(/\\Z/g, 'ℤ')
52+
.replace(/\\vec\{([^}]+)\}/g, '$1⃗')
53+
.replace(/\\deriv\{([^}]+)\}\{([^}]+)\}/g, 'd$1/d$2')
54+
.replace(/\\pdv\{([^}]+)\}\{([^}]+)\}/g, '∂$1/∂$2')
55+
.replace(/\\set\{([^}]+)\}/g, '{$1}')
56+
.replace(/\\lr\{([^}]+)\}/g, '($1)')
57+
.replace(/\\T/g, 'ᵀ')
58+
.replace(/\\defeq/g, '≔')
59+
.replace(/\\epsilon_0/g, 'ε₀')
60+
.replace(/\\mu_0/g, 'μ₀')
61+
.replace(/\\curl/g, '∇×')
62+
.replace(/\\grad/g, '∇')
63+
.replace(/\\laplacian/g, '∇²')
64+
.replace(/\\dd\{([^}]+)\}/g, 'd$1')
65+
.replace(/\\pd\{([^}]+)\}/g, '∂$1')
66+
.replace(/\\vb\{([^}]+)\}/g, '$1⃗')
67+
.replace(/\\vu\{([^}]+)\}/g, '$1̂')
68+
.replace(/\\aprx/g, '≈')
69+
.replace(/\\bra\{([^}]+)\}/g, '⟨$1|')
70+
.replace(/\\ket\{([^}]+)\}/g, '|$1⟩')
71+
.replace(/\\braket\{([^}]+)\}\{([^}]+)\}/g, '⟨$1|$2⟩')
72+
.replace(/\\oprod\{([^}]+)\}\{([^}]+)\}/g, '|$1⟩⟨$2|')
73+
.replace(/\\slashed\{([^}]+)\}/g, '$1/')
74+
75+
try {
76+
const markdownElement = (
77+
<ReactMarkdown
78+
remarkPlugins={[remarkGfm]}
79+
components={{
80+
code(props) {
81+
const { children, className } = props
82+
const match = /language-(\w+)/.exec(className || '')
83+
84+
if (match) {
85+
return (
86+
<div style={{ margin: '1rem 0', borderRadius: '8px', overflow: 'hidden' }}>
87+
<div
88+
style={{
89+
fontSize: '12px',
90+
padding: '8px 12px',
91+
backgroundColor: '#f8f9fa',
92+
borderBottom: '1px solid #e9ecef',
93+
}}
94+
>
95+
{match[1]}
96+
</div>
97+
<pre
98+
style={{
99+
backgroundColor: '#2d3748',
100+
color: '#e2e8f0',
101+
padding: '16px',
102+
margin: '0',
103+
fontSize: '16px',
104+
fontFamily: 'Monaco, Consolas, monospace',
105+
}}
106+
>
107+
<code>{String(children)}</code>
108+
</pre>
109+
</div>
110+
)
111+
}
112+
return (
113+
<code
114+
style={{
115+
backgroundColor: '#f1f3f4',
116+
padding: '2px 6px',
117+
borderRadius: '4px',
118+
fontFamily: 'Monaco, Consolas, monospace',
119+
fontSize: '16px',
120+
}}
121+
>
122+
{children}
123+
</code>
124+
)
125+
},
126+
}}
127+
>
128+
{emailFriendlyMath}
129+
</ReactMarkdown>
130+
)
131+
132+
return renderToStaticMarkup(markdownElement)
133+
} catch (error) {
134+
return escapeHtml(content)
135+
}
136+
}
137+
138+
const formatEmail = (messages: Message[], t: any): string => {
139+
const emailContent = messages
140+
.map(({ role, content }) => {
141+
const formattedContent = role === 'assistant' ? formatEmailContent(content) : escapeHtml(content)
142+
143+
return `
144+
<div style="padding: 2rem; ${
145+
role === 'user'
146+
? 'background: #efefef; margin-left: 20px; border-radius: 0.6rem; box-shadow: 0px 2px 2px rgba(0,0,0,0.2); white-space: pre-wrap; word-break: break-word; '
147+
: 'margin-right: 2rem;'
148+
}">
149+
<h3 style="font-style: italic; margin: 0; ${role === 'user' ? 'color: rgba(0, 0, 0, 0.8)' : 'color: #107eab'}">${t(`email:${role}`)}:</h3>
150+
${formattedContent}
151+
</div>
152+
`
153+
})
154+
.join('')
155+
156+
return `
157+
<html>
158+
<head>
159+
<meta charset="UTF-8">
160+
<style>
161+
body {
162+
color: rgba(0, 0, 0, 0.8);
163+
line-height: 1.6;
164+
margin: 0 auto;
165+
}
166+
</style>
167+
</head>
168+
<body>
169+
${emailContent}
170+
</body>
171+
</html>
172+
`
173+
}
174+
175+
const EmailButton = ({ messages, disabled }: { messages: Message[]; disabled: boolean }) => {
176+
const { t } = useTranslation()
177+
const { user, isLoading } = useCurrentUser()
178+
179+
if (isLoading || !user?.email) return null
180+
181+
const handleSend = async () => {
182+
if (!user.email) {
183+
enqueueSnackbar(t('email:failure'), { variant: 'error' })
184+
return
185+
}
186+
187+
const date = new Date()
188+
const formattedDate = `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}`
189+
const subject = `${t('chat:conversation')} ${formattedDate}`
190+
const text = formatEmail(messages, t)
191+
192+
const response = await sendEmail(user.email, text, subject)
193+
enqueueSnackbar(t(response.ok ? 'email:success' : 'email:failure'), { variant: response.ok ? 'success' : 'error' })
194+
}
195+
196+
return (
197+
<Tooltip title={<Typography variant="body2">{t('info:email', { email: user.email })}</Typography>}>
198+
<OutlineButtonBlack startIcon={<EmailIcon />} onClick={handleSend} disabled={disabled}>
199+
{t('email:save')}
200+
</OutlineButtonBlack>
201+
</Tooltip>
202+
)
203+
}
204+
205+
export default EmailButton

0 commit comments

Comments
 (0)