Skip to content

Commit 20f8bce

Browse files
committed
add stream stop mechanism and unfuck clear conversation while streaming
1 parent 581dd22 commit 20f8bce

File tree

3 files changed

+74
-25
lines changed

3 files changed

+74
-25
lines changed

src/client/components/ChatV2/ChatBox.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const ChatBox = ({
2828
handleContinue,
2929
handleSubmit,
3030
handleReset,
31+
handleStop,
3132
isMobile,
3233
}: {
3334
disabled: boolean
@@ -41,6 +42,7 @@ export const ChatBox = ({
4142
handleContinue: (message: string) => void
4243
handleSubmit: (message: string) => void
4344
handleReset: () => void
45+
handleStop: () => void
4446
isMobile: boolean
4547
}) => {
4648
const { courseId } = useParams()
@@ -229,9 +231,16 @@ export const ChatBox = ({
229231
{fileName && <Chip sx={{ borderRadius: 100 }} label={fileName} onDelete={handleDeleteFile} />}
230232
</Box>
231233
<Tooltip title={disabled ? t('chat:cancelResponse') : isShiftEnterSend ? t('chat:shiftEnterSend') : t('chat:enterSend')} arrow placement="top">
232-
<IconButton type={disabled ? 'button' : 'submit'} ref={sendButtonRef} data-testid="send-chat-message">
233-
{disabled ? <StopIcon /> : <Send />}
234-
</IconButton>
234+
{
235+
disabled ?
236+
<IconButton onClick={handleStop}>
237+
<StopIcon />
238+
</IconButton>
239+
:
240+
<IconButton type={'submit'} ref={sendButtonRef} data-testid="send-chat-message">
241+
<Send />
242+
</IconButton>
243+
}
235244
</Tooltip>
236245
<SendPreferenceConfiguratorModal
237246
open={sendPreferenceConfiguratorOpen}

src/client/components/ChatV2/ChatV2.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import ToolResult from './ToolResult'
2929
import { OutlineButtonBlack } from './general/Buttons'
3030
import { ChatInfo } from './general/ChatInfo'
3131
import { SettingsModal } from './SettingsModal'
32-
import { useChatStream } from './useChatStream'
32+
import { StreamAbortReason, TypedAbortController, useChatStream } from './useChatStream'
3333
import { postCompletionStreamV3 } from './api'
3434
import PromptSelector from './PromptSelector'
3535
import ModelSelector from './ModelSelector'
@@ -129,7 +129,7 @@ const ChatV2Content = () => {
129129

130130
const disclaimerInfo = infoTexts?.find((infoText) => infoText.name === 'disclaimer')?.text[i18n.language] ?? null
131131

132-
const { processStream, completion, isStreaming, setIsStreaming, toolCalls, streamController, generationInfo } = useChatStream({
132+
const { processStream, completion, isStreaming, setIsStreaming, toolCalls, streamControllerRef, generationInfo } = useChatStream({
133133
onComplete: ({ message }) => {
134134
if (message.content.length > 0) {
135135
setMessages((prev: ChatMessage[]) => prev.concat(message))
@@ -165,6 +165,8 @@ const ChatV2Content = () => {
165165
return
166166
}
167167

168+
streamControllerRef.current = new TypedAbortController<StreamAbortReason>()
169+
168170
const formData = new FormData()
169171

170172
const file = fileInputRef.current?.files?.[0]
@@ -184,8 +186,8 @@ const ChatV2Content = () => {
184186
}
185187
setFileName('')
186188
setRetryTimeout(() => {
187-
if (streamController) {
188-
streamController.abort()
189+
if (streamControllerRef.current) {
190+
streamControllerRef.current.abort("timeout_error")
189191
}
190192
}, 5000)
191193

@@ -209,7 +211,7 @@ const ChatV2Content = () => {
209211
},
210212
courseId,
211213
},
212-
streamController,
214+
streamControllerRef.current,
213215
)
214216

215217
if (!stream && !tokenUsageAnalysis) {
@@ -240,6 +242,7 @@ const ChatV2Content = () => {
240242

241243
const handleReset = () => {
242244
if (window.confirm(t('chat:emptyConfirm'))) {
245+
streamControllerRef.current?.abort("conversation_cleared")
243246
setMessages([])
244247
setActiveToolResult(undefined)
245248
if (fileInputRef.current) {
@@ -248,11 +251,6 @@ const ChatV2Content = () => {
248251
setFileName('')
249252
setTokenUsageWarning('')
250253
setTokenUsageAlertOpen(false)
251-
setRetryTimeout(() => {
252-
if (streamController) {
253-
streamController.abort()
254-
}
255-
}, 5000)
256254
clearRetryTimeout()
257255
dispatchAnalytics({ type: 'RESET_CHAT' })
258256
}
@@ -482,6 +480,7 @@ const ChatV2Content = () => {
482480
handleSendMessage(newMessage, false)
483481
}}
484482
handleReset={handleReset}
483+
handleStop={() => streamControllerRef.current?.abort("user_aborted")}
485484
isMobile={isMobile}
486485
/>
487486
</Box>

src/client/components/ChatV2/useChatStream.ts

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
1-
import { useState } from 'react'
1+
import { useRef, useState } from 'react'
22
import type { AssistantMessage, ChatEvent, MessageGenerationInfo, ToolCallResultEvent, ToolCallStatusEvent } from '../../../shared/chat'
33

4+
export type StreamAbortReason = 'timeout_error' | 'user_aborted' | 'conversation_cleared' | 'error'
5+
export class TypedAbortController<Reason extends string> {
6+
private inner = new AbortController()
7+
signal: AbortSignal
8+
9+
constructor() {
10+
this.signal = this.inner.signal
11+
}
12+
13+
abort(reason: Reason) {
14+
this.inner.abort(reason)
15+
}
16+
}
17+
418
type ToolCallState = ToolCallStatusEvent
519

620
export const useChatStream = ({
@@ -18,7 +32,7 @@ export const useChatStream = ({
1832
const [generationInfo, setGenerationInfo] = useState<MessageGenerationInfo | undefined>()
1933
const [isStreaming, setIsStreaming] = useState(false)
2034
const [toolCalls, setToolCalls] = useState<Record<string, ToolCallState>>({})
21-
const [streamController, setStreamController] = useState<AbortController>()
35+
const streamControllerRef = useRef<TypedAbortController<StreamAbortReason>>(null)
2236

2337
const decoder = new TextDecoder()
2438

@@ -84,15 +98,6 @@ export const useChatStream = ({
8498
}
8599
}
86100
}
87-
} catch (err: unknown) {
88-
error += '\nResponse stream was interrupted'
89-
onError(err)
90-
} finally {
91-
setStreamController(undefined)
92-
setCompletion('')
93-
setToolCalls({})
94-
setIsStreaming(false)
95-
setGenerationInfo(undefined)
96101

97102
onComplete({
98103
message: {
@@ -103,6 +108,42 @@ export const useChatStream = ({
103108
generationInfo: baseGenerationInfo,
104109
},
105110
})
111+
} catch (err: unknown) {
112+
if (err instanceof Error && err.name === 'AbortError') {
113+
const reason = streamControllerRef.current?.signal.reason as StreamAbortReason | undefined
114+
115+
switch (reason) {
116+
case 'timeout_error':
117+
error += '\nTimeout error'
118+
break
119+
120+
case 'user_aborted':
121+
onComplete({
122+
message: {
123+
role: 'assistant',
124+
content,
125+
error: error.length > 0 ? error : undefined,
126+
toolCalls: toolCallResultsAccum,
127+
generationInfo: baseGenerationInfo,
128+
},
129+
})
130+
return
131+
132+
case 'conversation_cleared':
133+
setCompletion('')
134+
return
135+
}
136+
} else {
137+
error += '\nResponse stream was interrupted'
138+
}
139+
140+
onError(err)
141+
} finally {
142+
streamControllerRef.current = null
143+
setCompletion('')
144+
setToolCalls({})
145+
setIsStreaming(false)
146+
setGenerationInfo(undefined)
106147
}
107148
}
108149

@@ -113,6 +154,6 @@ export const useChatStream = ({
113154
isStreaming,
114155
setIsStreaming,
115156
toolCalls,
116-
streamController,
157+
streamControllerRef,
117158
}
118159
}

0 commit comments

Comments
 (0)