Skip to content

Commit 3c0b287

Browse files
committed
chat v2 work begins...
1 parent ba0dd59 commit 3c0b287

File tree

8 files changed

+340
-0
lines changed

8 files changed

+340
-0
lines changed

src/client/Router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ import NoAccess from './components/NoAccess'
1717
import Chats from './components/Chats'
1818
import Statistics from './components/Statistics'
1919
import Rag from './components/Rag'
20+
import { ChatV2 } from './components/ChatV2/ChatV2'
2021

2122
const router = createBrowserRouter(
2223
createRoutesFromElements(
2324
<Route path="/" element={<App />}>
2425
<Route index element={<Chat />} />
26+
<Route path="/v2" element={<ChatV2 />} />
2527
<Route path="/:courseId" element={<Chat />} />
2628
<Route path="/courses" element={<Courses />} />
2729
<Route path="/courses/:id" element={<Course />} />
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Send } from '@mui/icons-material'
2+
import { IconButton, TextField } from '@mui/material'
3+
import { useState } from 'react'
4+
5+
export const ChatBox = ({
6+
disabled,
7+
onSubmit,
8+
}: {
9+
disabled: boolean
10+
onSubmit: (message: string) => void
11+
}) => {
12+
const [message, setMessage] = useState<string>('')
13+
14+
const handleSubmit = (e: React.FormEvent) => {
15+
e.preventDefault()
16+
if (message.trim()) {
17+
onSubmit(message)
18+
setMessage('')
19+
}
20+
}
21+
22+
return (
23+
<form onSubmit={handleSubmit}>
24+
<TextField
25+
value={message}
26+
onChange={(e) => setMessage(e.target.value)}
27+
placeholder="Type your message here..."
28+
fullWidth
29+
disabled={disabled}
30+
variant="outlined"
31+
size="small"
32+
sx={{ marginBottom: '1rem' }}
33+
slotProps={{
34+
input: {
35+
endAdornment: (
36+
<IconButton type="submit" disabled={disabled}>
37+
<Send />
38+
</IconButton>
39+
),
40+
},
41+
}}
42+
/>
43+
</form>
44+
)
45+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useParams } from 'react-router-dom'
2+
import useCourse from '../../hooks/useCourse'
3+
import useUserStatus from '../../hooks/useUserStatus'
4+
import { useRef, useState } from 'react'
5+
import useLocalStorageState from '../../hooks/useLocalStorageState'
6+
import { DEFAULT_MODEL } from '../../../config'
7+
import useInfoTexts from '../../hooks/useInfoTexts'
8+
import { Message } from '../../types'
9+
import useRetryTimeout from '../../hooks/useRetryTimeout'
10+
import { useTranslation } from 'react-i18next'
11+
import { handleCompletionStreamError } from './error'
12+
import { Box } from '@mui/material'
13+
import { Disclaimer } from './Disclaimer'
14+
import { Conversation } from './Conversation'
15+
import { ChatBox } from './ChatBox'
16+
17+
export const ChatV2 = () => {
18+
const { courseId } = useParams()
19+
20+
const { course } = useCourse(courseId)
21+
const {
22+
userStatus,
23+
isLoading: statusLoading,
24+
refetch: refetchStatus,
25+
} = useUserStatus(courseId)
26+
const [model, setModel] = useLocalStorageState(DEFAULT_MODEL, 'model')
27+
const { infoTexts, isLoading: infoTextsLoading } = useInfoTexts()
28+
const [activePromptId, setActivePromptId] = useState('')
29+
const [system, setSystem] = useLocalStorageState('general-chat-system', '')
30+
const [message, setMessage] = useLocalStorageState('general-chat-current', '')
31+
const [messages, setMessages] = useLocalStorageState<Message[]>(
32+
'general-chat-messages',
33+
[]
34+
)
35+
const inputFileRef = useRef<HTMLInputElement>(null)
36+
const [fileName, setFileName] = useState<string>('')
37+
const [completion, setCompletion] = useState('')
38+
const [streamController, setStreamController] = useState<AbortController>()
39+
const [alertOpen, setAlertOpen] = useState(false)
40+
const [disallowedFileType, setDisallowedFileType] = useState('')
41+
const [tokenUsageWarning, setTokenUsageWarning] = useState('')
42+
const [tokenWarningVisible, setTokenWarningVisible] = useState(false)
43+
const [modelTemperature, setModelTemperature] = useState(0.5)
44+
const [setRetryTimeout, clearRetryTimeout] = useRetryTimeout()
45+
const [saveConsent, setSaveConsent] = useState(true)
46+
47+
const { t, i18n } = useTranslation()
48+
const { language } = i18n
49+
50+
const disclaimerInfo =
51+
infoTexts?.find((infoText) => infoText.name === 'disclaimer')?.text[
52+
language
53+
] ?? null
54+
const systemMessageInfo =
55+
infoTexts?.find((infoText) => infoText.name === 'systemMessage')?.text[
56+
language
57+
] ?? null
58+
59+
const processStream = async (stream: ReadableStream) => {
60+
try {
61+
const reader = stream.getReader()
62+
63+
let content = ''
64+
const decoder = new TextDecoder()
65+
66+
while (true) {
67+
const { value, done } = await reader.read()
68+
if (done) break
69+
70+
const text = decoder.decode(value)
71+
setCompletion((prev) => prev + text)
72+
content += text
73+
}
74+
75+
setMessages(messages.concat({ role: 'assistant', content }))
76+
} catch (err: any) {
77+
handleCompletionStreamError(err, fileName)
78+
} finally {
79+
setStreamController(undefined)
80+
setCompletion('')
81+
refetchStatus()
82+
inputFileRef.current.value = ''
83+
setFileName('')
84+
clearRetryTimeout()
85+
}
86+
}
87+
88+
return (
89+
<Box>
90+
{disclaimerInfo && <Disclaimer disclaimer={disclaimerInfo} />}
91+
<Conversation messages={messages} />
92+
<ChatBox
93+
disabled={false}
94+
onSubmit={(message) => {
95+
if (message.trim()) {
96+
setMessages(messages.concat({ role: 'user', content: message }))
97+
setMessage('')
98+
setCompletion('')
99+
setStreamController(new AbortController())
100+
setRetryTimeout(() => {
101+
if (streamController) {
102+
streamController.abort()
103+
}
104+
}, 5000)
105+
}
106+
}}
107+
/>
108+
</Box>
109+
)
110+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Box } from '@mui/material'
2+
import { Message } from '../../types'
3+
import ReactMarkdown from 'react-markdown'
4+
import remarkGfm from 'remark-gfm'
5+
6+
const MessageItem = ({ message }: { message: Message }) => (
7+
<Box>
8+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
9+
</Box>
10+
)
11+
12+
export const Conversation = ({ messages }: { messages: Message[] }) => (
13+
<Box>
14+
{messages.map((message, idx) => (
15+
<MessageItem key={idx} message={message} />
16+
))}
17+
</Box>
18+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
Button,
3+
Dialog,
4+
DialogActions,
5+
DialogContent,
6+
DialogTitle,
7+
} from '@mui/material'
8+
import useLocalStorageState from '../../hooks/useLocalStorageState'
9+
import Markdown from '../Banner/Markdown'
10+
11+
export const Disclaimer = ({ disclaimer }: { disclaimer: string }) => {
12+
const [disclaimerClosed, setDisclaimerClosed] = useLocalStorageState<boolean>(
13+
'disclaimerClosed',
14+
false
15+
)
16+
17+
return (
18+
<>
19+
<Button
20+
variant="outlined"
21+
onClick={() => setDisclaimerClosed(false)}
22+
size="small"
23+
>
24+
Disclaimer
25+
</Button>
26+
<Dialog
27+
open={!disclaimerClosed}
28+
onClose={() => setDisclaimerClosed(true)}
29+
>
30+
<DialogTitle>Disclaimer</DialogTitle>
31+
<DialogContent>
32+
<Markdown>{disclaimer}</Markdown>
33+
</DialogContent>
34+
<DialogActions>
35+
<Button onClick={() => setDisclaimerClosed(true)} color="primary">
36+
Close
37+
</Button>
38+
</DialogActions>
39+
</Dialog>
40+
</>
41+
)
42+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { t } from 'i18next'
2+
import { enqueueSnackbar } from 'notistack'
3+
4+
/**
5+
* Handle error messages related to completion stream creation
6+
*/
7+
export const handleCompletionStreamError = (err: any, file: string) => {
8+
if (err?.name === 'AbortError' || !err) return
9+
10+
const error = err?.response?.data || err.message
11+
12+
if (error === 'Model maximum context reached' && file) {
13+
enqueueSnackbar(t('error:tooLargeFile'), { variant: 'error' })
14+
} else if (error === 'Error parsing file' && file) {
15+
enqueueSnackbar(t('error:fileParsingError'), { variant: 'error' })
16+
} else if (error === 'TimeoutError') {
17+
enqueueSnackbar(t('error:waitingForResponse'), { variant: 'error' })
18+
} else {
19+
console.error('Unexpected error in completion stream: ', error)
20+
enqueueSnackbar(t('error:unexpected'), { variant: 'error' })
21+
}
22+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Message } from '../../types'
2+
import { postAbortableStream } from '../../util/apiClient'
3+
4+
interface GetCompletoinStreamProps {
5+
system: string
6+
messages: Message[]
7+
model: string
8+
formData: FormData
9+
userConsent: boolean
10+
modelTemperature: number
11+
courseId?: string
12+
abortController?: AbortController
13+
saveConsent: boolean
14+
}
15+
export const getCompletionStream = async ({
16+
system,
17+
messages,
18+
model,
19+
formData,
20+
userConsent,
21+
modelTemperature,
22+
courseId,
23+
abortController,
24+
saveConsent,
25+
}: GetCompletoinStreamProps) => {
26+
const data = {
27+
courseId,
28+
options: {
29+
messages: [
30+
{
31+
role: 'system',
32+
content: system,
33+
},
34+
...messages,
35+
],
36+
model,
37+
userConsent,
38+
modelTemperature,
39+
saveConsent,
40+
},
41+
}
42+
43+
formData.set('data', JSON.stringify(data))
44+
45+
return postAbortableStream('/ai/stream', formData, abortController)
46+
}
47+
48+
interface GetCourseCompletionStreamProps {
49+
id: string
50+
system: string
51+
messages: Message[]
52+
model: string
53+
courseId: string
54+
abortController?: AbortController
55+
}
56+
export const getCourseCompletionStream = async ({
57+
id,
58+
system,
59+
messages,
60+
model,
61+
courseId,
62+
abortController,
63+
}: GetCourseCompletionStreamProps) => {
64+
const data = {
65+
id,
66+
courseId,
67+
options: {
68+
messages: [
69+
{
70+
role: 'system',
71+
content: system,
72+
},
73+
...messages,
74+
],
75+
model,
76+
},
77+
}
78+
const formData = new FormData()
79+
formData.set('data', JSON.stringify(data))
80+
81+
return postAbortableStream(`/ai/stream/`, formData, abortController)
82+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useState, useEffect } from 'react'
2+
3+
function useLocalStorageState<T>(
4+
key: string,
5+
defaultValue: T
6+
): [T, (value: T) => void] {
7+
const [state, setState] = useState<T>(() => {
8+
const storedValue = localStorage.getItem(key)
9+
return storedValue !== null ? JSON.parse(storedValue) : defaultValue
10+
})
11+
12+
useEffect(() => {
13+
localStorage.setItem(key, JSON.stringify(state))
14+
}, [key, state])
15+
16+
return [state, setState]
17+
}
18+
19+
export default useLocalStorageState

0 commit comments

Comments
 (0)