Skip to content

Commit fae9893

Browse files
feat: add stop button to interrupt agent response
- Implement 'stop' button in `ChatInput` that appears during streaming - Disable textarea while streaming to prevent concurrent messages - Map 'Enter' key to stop generation during streaming - Connect `stopGeneration` from `useChat` hook to the UI Co-authored-by: MrOrz <108608+MrOrz@users.noreply.github.com>
1 parent 8b57085 commit fae9893

3 files changed

Lines changed: 57 additions & 30 deletions

File tree

src/components/ChatArea.tsx

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { useEffect, useRef } from 'react'
2-
import React from 'react'
1+
import React, { useEffect, useRef } from 'react'
32
import { UserMessage } from './UserMessage'
43
import { AgentMessage } from './AgentMessage'
54
import { FeedbackButtons } from './FeedbackButtons'
@@ -10,12 +9,14 @@ interface ChatAreaProps {
109
messages: Array<ChatMessage>
1110
isStreaming: boolean
1211
onSendMessage: (text: string) => void
12+
onStop?: () => void
1313
}
1414

1515
export function ChatArea({
1616
messages,
1717
isStreaming,
1818
onSendMessage,
19+
onStop,
1920
}: ChatAreaProps) {
2021
const scrollRef = useRef<HTMLDivElement>(null)
2122

@@ -34,47 +35,57 @@ export function ChatArea({
3435
className="flex-1 overflow-y-auto p-4 md:p-6 chat-container pb-0"
3536
>
3637
{messages.map((msg, index) => {
37-
const prevMsg: ChatMessage | undefined = messages[index - 1];
38-
const nextMsg: ChatMessage | undefined = messages[index + 1];
38+
const prevMsg = messages[index - 1] as ChatMessage | undefined
39+
const nextMsg = messages[index + 1] as ChatMessage | undefined
3940

40-
return <React.Fragment key={msg.id}>
41-
{msg.author === 'user'
42-
? <UserMessage message={msg} />
43-
: <AgentMessage
44-
message={msg}
45-
showAvatar={msg.author !== prevMsg?.author}
46-
/>}
47-
{
48-
/* Show thumbs up/down when all below are true:
41+
return (
42+
<React.Fragment key={msg.id}>
43+
{msg.author === 'user' ? (
44+
<UserMessage message={msg} />
45+
) : (
46+
<AgentMessage
47+
message={msg}
48+
showAvatar={msg.author !== prevMsg?.author}
49+
/>
50+
)}
51+
{
52+
/* Show thumbs up/down when all below are true:
4953
- Message has trace id
5054
- Message is not streaming
5155
- Next message has different trace id or doesn't exist
5256
*/
53-
msg.langfuseTraceId &&
54-
!msg.isStreaming &&
55-
(!nextMsg?.langfuseTraceId || msg.langfuseTraceId !== nextMsg.langfuseTraceId) &&
56-
<FeedbackButtons traceId={msg.langfuseTraceId} />
57-
}
58-
</React.Fragment>
57+
msg.langfuseTraceId &&
58+
!msg.isStreaming &&
59+
(!nextMsg?.langfuseTraceId ||
60+
msg.langfuseTraceId !== nextMsg.langfuseTraceId) && (
61+
<FeedbackButtons traceId={msg.langfuseTraceId} />
62+
)
63+
}
64+
</React.Fragment>
65+
)
5966
})}
6067

61-
{
62-
isStreaming && <p className="flex items-center gap-2 text-gray-500 mt-2">
68+
{isStreaming && (
69+
<p className="flex items-center gap-2 text-gray-500 mt-2">
6370
正在思考中
6471
<span className="typing-indicator ml-1">
6572
<span />
6673
<span />
6774
<span />
6875
</span>
6976
</p>
70-
}
77+
)}
7178

7279
{/* Extra space at the bottom */}
7380
<div className="h-4" />
7481
</div>
7582

7683
{/* Input area */}
77-
<ChatInput onSend={onSendMessage} disabled={isStreaming} />
84+
<ChatInput
85+
onSend={onSendMessage}
86+
onStop={onStop}
87+
isStreaming={isStreaming}
88+
/>
7889
</>
7990
)
8091
}

src/components/ChatInput.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'
22

33
interface ChatInputProps {
44
onSend: (text: string) => void
5+
onStop?: () => void
6+
isStreaming?: boolean
57
disabled?: boolean
68
}
79

8-
export function ChatInput({ onSend, disabled }: ChatInputProps) {
10+
export function ChatInput({
11+
onSend,
12+
onStop,
13+
isStreaming,
14+
disabled,
15+
}: ChatInputProps) {
916
const [value, setValue] = useState('')
1017
const textareaRef = useRef<HTMLTextAreaElement>(null)
1118

@@ -33,19 +40,25 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
3340
onKeyDown={(e) => {
3441
if (e.key === 'Enter' && !e.shiftKey) {
3542
e.preventDefault()
36-
handleSubmit()
43+
if (isStreaming) {
44+
onStop?.()
45+
} else {
46+
handleSubmit()
47+
}
3748
}
3849
}}
39-
disabled={disabled}
50+
disabled={disabled || isStreaming}
4051
className="w-full bg-transparent border-none focus:ring-0 p-3 pr-12 min-h-[50px] max-h-32 resize-none text-sm rounded-xl"
4152
placeholder="詢問後續問題或要求修改..."
4253
/>
4354
<button
44-
onClick={handleSubmit}
45-
disabled={!value.trim() || disabled}
55+
onClick={isStreaming ? onStop : handleSubmit}
56+
disabled={(!isStreaming && !value.trim()) || disabled}
4657
className="absolute right-2 bottom-2 p-1.5 bg-primary text-black rounded-lg hover:bg-primary-hover transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
4758
>
48-
<span className="material-symbols-outlined text-sm">send</span>
59+
<span className="material-symbols-outlined text-sm">
60+
{isStreaming ? 'stop' : 'send'}
61+
</span>
4962
</button>
5063
</div>
5164
<div className="text-center mt-2">

src/routes/_app/session.$sessionId.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export const Route = createFileRoute('/_app/session/$sessionId')({
88

99
function SessionPage() {
1010
const { sessionId } = useParams({ from: '/_app/session/$sessionId' })
11-
const { messages, isStreaming, error, sendMessage } = useChat({ sessionId })
11+
const { messages, isStreaming, error, sendMessage, stopGeneration } = useChat(
12+
{ sessionId },
13+
)
1214

1315
return (
1416
<>
@@ -28,6 +30,7 @@ function SessionPage() {
2830
messages={messages}
2931
isStreaming={isStreaming}
3032
onSendMessage={sendMessage}
33+
onStop={stopGeneration}
3134
/>
3235
</>
3336
)

0 commit comments

Comments
 (0)