Skip to content

Commit 08327fa

Browse files
committed
Merge branch 'main' into langchain-rag
2 parents 76e7715 + 9fb2a30 commit 08327fa

File tree

14 files changed

+222
-92
lines changed

14 files changed

+222
-92
lines changed

e2e/chat.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test.describe('Chat v2 Conversation tests', () => {
99
})
1010

1111
test('Chat v2 mock response works', async ({ page }) => {
12-
await page.locator('#model-selector').first().click()
12+
await page.getByTestId('model-selector').first().click()
1313
await page.getByRole('option', { name: 'mock' }).click()
1414

1515
const chatInput = page.locator('#chat-input').first()
@@ -24,7 +24,7 @@ test.describe('Chat v2 Conversation tests', () => {
2424
})
2525

2626
test('Can empty conversation', async ({ page }) => {
27-
await page.locator('#model-selector').first().click()
27+
await page.getByTestId('model-selector').first().click()
2828
await page.getByRole('option', { name: 'mock' }).click()
2929

3030
const chatInput = page.locator('#chat-input').first()

e2e/courseChatRag.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test.describe('Course Chat v2', () => {
99
})
1010

1111
test('Course chat works', async ({ page }) => {
12-
await page.locator('#model-selector').first().click()
12+
await page.getByTestId('model-selector').first().click()
1313
await page.getByRole('option', { name: 'mock' }).click()
1414

1515
const chatInput = page.locator('#chat-input').first()

src/client/App.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import { Box, Button, CssBaseline, Snackbar } from '@mui/material'
1+
import { Box, Button, CircularProgress, CssBaseline, Snackbar } from '@mui/material'
22
import { ThemeProvider } from '@mui/material/styles'
33
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFnsV3'
44
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
55
import { fi } from 'date-fns/locale'
66
import { SnackbarProvider } from 'notistack'
77
import React, { useEffect, useRef } from 'react'
8-
import { Outlet, useLocation, useParams } from 'react-router-dom'
8+
import { Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
99
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
1010
import { PUBLIC_URL } from '../config'
1111
import { Feedback } from './components/Feedback'
12-
import Footer from './components/Footer'
1312
import NavBar from './components/NavBar'
1413
import { AppContext } from './contexts/AppContext'
1514
import { EmbeddedProvider, useIsEmbedded } from './contexts/EmbeddedContext'
@@ -75,23 +74,10 @@ const AdminLoggedInAsBanner = () => {
7574
const App = () => {
7675
useUpdateUrlLang()
7776
const theme = useTheme()
78-
const { courseId } = useParams()
79-
const location = useLocation()
80-
const { user, isLoading } = useCurrentUser()
8177

8278
useEffect(() => {
8379
initShibbolethPinger()
8480
}, [])
85-
const onNoAccessPage = location.pathname.includes('/noaccess')
86-
87-
if (isLoading && !onNoAccessPage) return null
88-
89-
if (!onNoAccessPage && !hasAccess(user, courseId)) {
90-
window.location.href = PUBLIC_URL + getRedirect(user)
91-
return null
92-
}
93-
94-
if (!user && !onNoAccessPage) return null
9581

9682
return (
9783
<ThemeProvider theme={theme}>
@@ -127,7 +113,7 @@ const Layout = () => {
127113
>
128114
{!isEmbedded && <NavBar />}
129115
<Box sx={{ flex: 1 }}>
130-
<Outlet />
116+
<Content />
131117
</Box>
132118
<Feedback />
133119
</Box>
@@ -136,4 +122,26 @@ const Layout = () => {
136122
)
137123
}
138124

125+
const Content = () => {
126+
const { courseId } = useParams()
127+
const location = useLocation()
128+
const { user, isLoading } = useCurrentUser()
129+
130+
const onNoAccessPage = location.pathname.includes('/noaccess')
131+
132+
if (isLoading && !onNoAccessPage) return <CircularProgress sx={{ margin: 'auto' }} />
133+
134+
if (!onNoAccessPage && !hasAccess(user, courseId)) {
135+
return <Navigate to={PUBLIC_URL + getRedirect(user)} />
136+
}
137+
138+
if (!user && !onNoAccessPage) return null
139+
140+
return (
141+
<Box sx={{ flex: 1 }}>
142+
<Outlet />
143+
</Box>
144+
)
145+
}
146+
139147
export default App

src/client/components/ChatV2/ChatBox.tsx

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { BlueButton, GrayButton, OutlineButtonBlack } from './general/Buttons'
1515
import { useIsEmbedded } from '../../contexts/EmbeddedContext'
1616
import useCurrentUser from '../../hooks/useCurrentUser'
1717
import { SendPreferenceConfiguratorModal, ShiftEnterForNewline, ShiftEnterToSend } from './SendPreferenceConfigurator'
18+
import { KeyCombinations, useKeyboardCommands } from './useKeyboardCommands'
1819

1920
export const ChatBox = ({
2021
disabled,
@@ -36,6 +37,7 @@ export const ChatBox = ({
3637
handleContinue,
3738
handleSubmit,
3839
handleReset,
40+
isMobile,
3941
}: {
4042
disabled: boolean
4143
currentModel: string
@@ -56,6 +58,7 @@ export const ChatBox = ({
5658
handleContinue: (message: string) => void
5759
handleSubmit: (message: string) => void
5860
handleReset: () => void
61+
isMobile: boolean
5962
}) => {
6063
const { courseId } = useParams()
6164
const isEmbedded = useIsEmbedded()
@@ -67,14 +70,23 @@ export const ChatBox = ({
6770
const [fileTypeAlertOpen, setFileTypeAlertOpen] = useState<boolean>(false)
6871
const [sendPreferenceConfiguratorOpen, setSendPreferenceConfiguratorOpen] = useState<boolean>(false)
6972
const sendButtonRef = useRef<HTMLButtonElement>(null)
70-
7173
const textFieldRef = useRef<HTMLInputElement>(null)
7274
const [message, setMessage] = useState<string>('')
7375

7476
const acuallyDisabled = disabled || message.length === 0
7577

7678
const { t } = useTranslation()
7779

80+
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState<boolean>(false)
81+
useKeyboardCommands({
82+
resetChat: handleReset,
83+
openModelSelector: () => {
84+
setIsModelSelectorOpen(true)
85+
},
86+
}) // @todo what key combination to open model selector
87+
88+
const isShiftEnterSend = user?.preferences?.sendShortcutMode === 'shift+enter' || !user?.preferences?.sendShortcutMode
89+
7890
const handleDeleteFile = () => {
7991
if (fileInputRef.current) {
8092
fileInputRef.current.value = ''
@@ -166,7 +178,7 @@ export const ChatBox = ({
166178
onSubmit={onSubmit}
167179
onKeyDown={(e) => {
168180
if (e.key === 'Enter') {
169-
if (user?.preferences?.sendShortcutMode === 'enter') {
181+
if (!isShiftEnterSend) {
170182
if (e.shiftKey) {
171183
// Do nothing with this event, it will result in a newline being inserted
172184
} else {
@@ -225,18 +237,30 @@ export const ChatBox = ({
225237
/>
226238
</IconButton>
227239
</Tooltip>
228-
<Tooltip title={t('chat:emptyConversation')} arrow placement="top">
240+
<Tooltip title={t('chat:emptyConversationTooltip', { hint: KeyCombinations.RESET_CHAT?.hint })} arrow placement="top">
229241
<IconButton onClick={handleReset}>
230242
<RestartAltIcon />
231243
</IconButton>
232244
</Tooltip>
233245
{fileName && <Chip sx={{ borderRadius: 100 }} label={fileName} onDelete={handleDeleteFile} />}
234246
{!isEmbedded && (
235-
<ModelSelector currentModel={currentModel} setModel={setModel} availableModels={availableModels} isTokenLimitExceeded={isTokenLimitExceeded} />
247+
<ModelSelector
248+
currentModel={currentModel}
249+
setModel={setModel}
250+
availableModels={availableModels}
251+
isTokenLimitExceeded={isTokenLimitExceeded}
252+
isOpen={isModelSelectorOpen}
253+
setIsOpen={(open) => {
254+
setIsModelSelectorOpen(open)
255+
if (!open) {
256+
setTimeout(() => textFieldRef.current?.focus(), 0) // setTimeout required here...
257+
}
258+
}}
259+
/>
236260
)}
237261
</Box>
238262

239-
<Tooltip title={disabled ? t('chat:cancelResponse') : t('chat:shiftEnter')} arrow placement="top">
263+
<Tooltip title={disabled ? t('chat:cancelResponse') : isShiftEnterSend ? t('chat:shiftEnterSend') : t('chat:enterSend')} arrow placement="top">
240264
<IconButton type={disabled ? 'button' : 'submit'} ref={sendButtonRef}>
241265
{disabled ? <StopIcon /> : <Send />}
242266
</IconButton>
@@ -271,19 +295,21 @@ export const ChatBox = ({
271295
</Tooltip>
272296
</Box>
273297

274-
<Typography
275-
sx={{
276-
display: { sm: 'none', md: 'block' },
277-
ml: 'auto',
278-
opacity: acuallyDisabled ? 0 : 1,
279-
transition: 'opacity 0.2s ease-in-out',
280-
fontSize: '14px',
281-
}}
282-
variant="body1"
283-
color="textSecondary"
284-
>
285-
{user?.preferences?.sendShortcutMode === 'enter' ? <ShiftEnterForNewline t={t} /> : <ShiftEnterToSend t={t} />}
286-
</Typography>
298+
{!isMobile && (
299+
<Typography
300+
sx={{
301+
display: { sm: 'none', md: 'block' },
302+
ml: 'auto',
303+
opacity: acuallyDisabled ? 0 : 1,
304+
transition: 'opacity 0.2s ease-in-out',
305+
fontSize: '14px',
306+
}}
307+
variant="body1"
308+
color="textSecondary"
309+
>
310+
{isShiftEnterSend ? <ShiftEnterToSend t={t} /> : <ShiftEnterForNewline t={t} />}
311+
</Typography>
312+
)}
287313

288314
{!isEmbedded && (
289315
<Tooltip

src/client/components/ChatV2/ChatV2.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ export const ChatV2 = () => {
512512
handleSubmit(newMessage, false)
513513
}}
514514
handleReset={handleReset}
515+
isMobile={isMobile}
515516
/>
516517
</Box>
517518
</Box>

src/client/components/ChatV2/ModelSelector.tsx

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
import { useTranslation } from 'react-i18next'
2-
import { MenuItem, FormControl, Select, SelectChangeEvent, Typography } from '@mui/material'
2+
import { MenuItem, FormControl, Select, SelectChangeEvent, Typography, Tooltip } from '@mui/material'
33
import { FREE_MODEL } from '../../../config'
4+
import { KeyCombinations } from './useKeyboardCommands'
5+
import React from 'react'
46

57
const ModelSelector = ({
68
currentModel,
79
setModel,
810
availableModels,
911
isTokenLimitExceeded,
12+
isOpen,
13+
setIsOpen,
1014
}: {
1115
currentModel: string
1216
setModel: (model: string) => void
1317
availableModels: string[]
1418
isTokenLimitExceeded: boolean
19+
isOpen: boolean
20+
setIsOpen: (isOpen: boolean) => void
1521
}) => {
1622
const { t } = useTranslation()
23+
const selectRef = React.useRef<HTMLSelectElement>(null)
1724

25+
/**
26+
* Extra tooltip logic because kb shortcut focusing would leave the tooltip annoyingly open without this.
27+
*/
28+
const [tooltipOpen, setTooltipOpen] = React.useState(false)
1829
const validModel = availableModels.includes(currentModel) ? currentModel : ''
1930

2031
return (
@@ -26,43 +37,76 @@ const ModelSelector = ({
2637
}}
2738
size="small"
2839
>
29-
<Select
30-
sx={{
31-
'& .MuiOutlinedInput-notchedOutline': {
32-
border: 'none',
33-
},
34-
'& .MuiSelect-select': {
35-
overflow: 'hidden',
36-
whiteSpace: 'nowrap',
37-
paddingLeft: { xs: '4px !important', md: '10px !important' },
38-
paddingRight: { xs: '20px !important', md: '30px !important' },
39-
maxWidth: { xs: '50px', md: '240px' },
40-
},
41-
}}
42-
id="model-selector"
43-
value={validModel}
44-
onChange={(event: SelectChangeEvent) => setModel(event.target.value)}
40+
<Tooltip
41+
title={t('chat:modelSelectorTooltip', { hint: KeyCombinations.OPEN_MODEL_SELECTOR?.hint })}
42+
arrow
43+
placement="top"
44+
disableFocusListener
45+
disableHoverListener
46+
onPointerEnter={() => setTooltipOpen(true)}
47+
onFocus={() => setTooltipOpen(true)}
48+
onBlur={() => setTooltipOpen(false)}
49+
onPointerLeave={() => setTooltipOpen(false)}
50+
ref={selectRef}
51+
open={tooltipOpen}
4552
>
46-
{availableModels.map((model) => (
47-
<MenuItem key={model} value={model} disabled={isTokenLimitExceeded && model !== FREE_MODEL}>
48-
<Typography>
49-
{model}
50-
{model === FREE_MODEL && (
51-
<Typography component="span" sx={{ fontStyle: 'italic', opacity: 0.8 }}>
52-
{' '}
53-
({t('chat:freeModel')})
54-
</Typography>
55-
)}
56-
{isTokenLimitExceeded && model !== FREE_MODEL && (
57-
<Typography component="span" sx={{ fontStyle: 'italic', opacity: 0.8 }}>
58-
{' '}
59-
({t('chat:modelDisabled')})
60-
</Typography>
61-
)}
62-
</Typography>
63-
</MenuItem>
64-
))}
65-
</Select>
53+
<Select
54+
ref={selectRef}
55+
open={isOpen}
56+
onOpen={() => {
57+
setIsOpen(true)
58+
setTooltipOpen(true)
59+
}}
60+
onClose={(event) => {
61+
event.stopPropagation()
62+
setTooltipOpen(false)
63+
selectRef.current?.blur()
64+
setIsOpen(false)
65+
}}
66+
sx={{
67+
'& .MuiOutlinedInput-notchedOutline': {
68+
border: 'none',
69+
},
70+
'& .MuiSelect-select': {
71+
overflow: 'hidden',
72+
whiteSpace: 'nowrap',
73+
paddingLeft: { xs: '4px !important', md: '10px !important' },
74+
paddingRight: { xs: '20px !important', md: '30px !important' },
75+
maxWidth: { xs: '50px', md: '240px' },
76+
transition: 'background-color 0.3s ease',
77+
'&:hover': {
78+
backgroundColor: 'action.hover',
79+
},
80+
'&:focus': {
81+
backgroundColor: 'action.focus',
82+
},
83+
},
84+
}}
85+
data-testid="model-selector"
86+
value={validModel}
87+
onChange={(event: SelectChangeEvent) => setModel(event.target.value)}
88+
>
89+
{availableModels.map((model) => (
90+
<MenuItem key={model} value={model} disabled={isTokenLimitExceeded && model !== FREE_MODEL}>
91+
<Typography>
92+
{model}
93+
{model === FREE_MODEL && (
94+
<Typography component="span" sx={{ fontStyle: 'italic', opacity: 0.8 }}>
95+
{' '}
96+
({t('chat:freeModel')})
97+
</Typography>
98+
)}
99+
{isTokenLimitExceeded && model !== FREE_MODEL && (
100+
<Typography component="span" sx={{ fontStyle: 'italic', opacity: 0.8 }}>
101+
{' '}
102+
({t('chat:modelDisabled')})
103+
</Typography>
104+
)}
105+
</Typography>
106+
</MenuItem>
107+
))}
108+
</Select>
109+
</Tooltip>
66110
</FormControl>
67111
)
68112
}

0 commit comments

Comments
 (0)