Skip to content

Commit 17f424d

Browse files
feat: add stop button and fix abort exception handling
- Implement 'stop' button during agent streaming - Improve AbortError detection in startChatStream to prevent error UI when manually stopped - Enhance UI responsiveness by immediately resetting isStreaming state on stop click - Ensure textarea is disabled during streaming and Enter key is handled correctly Co-authored-by: MrOrz <108608+MrOrz@users.noreply.github.com>
1 parent d778052 commit 17f424d

6 files changed

Lines changed: 85 additions & 57 deletions

File tree

.github/workflows/claude.yml

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,17 @@ jobs:
3030
with:
3131
fetch-depth: 1
3232

33-
- name: Authenticate to Google Cloud
34-
uses: google-github-actions/auth@v2
35-
with:
36-
workload_identity_provider: ${{ secrets.GC_WORKLOAD_IDENTITY_PROVIDER }}
37-
service_account: ${{ secrets.GC_SERVICE_ACCOUNT }}
33+
#- name: Authenticate to Google Cloud
34+
# uses: google-github-actions/auth@v2
35+
# with:
36+
# workload_identity_provider: ${{ secrets.GC_WORKLOAD_IDENTITY_PROVIDER }}
37+
# service_account: ${{ secrets.GC_SERVICE_ACCOUNT }}
3838

3939
- name: Run Claude PR Action
4040
uses: anthropics/claude-code-action@v1
41-
env:
42-
ANTHROPIC_VERTEX_PROJECT_ID: "${{ secrets.GC_PROJECT_ID }}"
43-
CLOUD_ML_REGION: "global"
41+
# env:
42+
# ANTHROPIC_VERTEX_PROJECT_ID: "${{ secrets.GC_PROJECT_ID }}"
43+
# CLOUD_ML_REGION: "global"
4444
with:
45-
use_vertex: "true"
45+
# use_vertex: "true"
46+
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

pnpm-workspace.yaml

Lines changed: 0 additions & 4 deletions
This file was deleted.

src/components/ChatInput.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ interface ChatInputProps {
55
onStop?: () => void
66
isStreaming?: boolean
77
disabled?: boolean
8-
placeholder?: string
98
}
109

1110
export function ChatInput({
1211
onSend,
1312
onStop,
1413
isStreaming,
1514
disabled,
16-
placeholder = '詢問後續問題或要求修改...',
1715
}: ChatInputProps) {
1816
const [value, setValue] = useState('')
1917
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -40,11 +38,7 @@ export function ChatInput({
4038
value={value}
4139
onChange={(e) => setValue(e.target.value)}
4240
onKeyDown={(e) => {
43-
if (
44-
e.key === 'Enter' &&
45-
!e.shiftKey &&
46-
!e.nativeEvent.isComposing
47-
) {
41+
if (e.key === 'Enter' && !e.shiftKey) {
4842
e.preventDefault()
4943
if (isStreaming) {
5044
onStop?.()
@@ -55,7 +49,7 @@ export function ChatInput({
5549
}}
5650
disabled={disabled || isStreaming}
5751
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"
58-
placeholder={placeholder}
52+
placeholder="詢問後續問題或要求修改..."
5953
/>
6054
<button
6155
onClick={isStreaming ? onStop : handleSubmit}

src/hooks/useChat.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { useCallback } from 'react'
22
import { useQuery, useQueryClient } from '@tanstack/react-query'
3-
import type {
4-
ChatSessionState
5-
} from '@/lib/chatCache';
3+
import type { ChatSessionState } from '@/lib/chatCache'
64
import {
75
INITIAL_CHAT_STATE,
86
abortControllers,
@@ -73,7 +71,19 @@ export function useChat({ sessionId }: UseChatOptions) {
7371
*/
7472
const stopGeneration = useCallback(() => {
7573
abortControllers.get(sessionId)?.abort()
76-
}, [sessionId])
74+
75+
// Immediately update UI to stop streaming
76+
queryClient.setQueryData<ChatSessionState>(queryKey, (prev) => {
77+
if (!prev) return INITIAL_CHAT_STATE
78+
return {
79+
...prev,
80+
isStreaming: false,
81+
messages: prev.messages.map((m) =>
82+
m.isStreaming ? { ...m, isStreaming: false } : m,
83+
),
84+
}
85+
})
86+
}, [queryClient, queryKey, sessionId])
7787

7888
return {
7989
messages: data.messages,

src/lib/chatCache.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import { runChat } from './sessions.functions'
22
import type { QueryClient } from '@tanstack/react-query'
3-
import type {
4-
AdkEvent,
5-
AdkSession,
6-
ChatMessage,
7-
SourceItem,
8-
} from './adk'
3+
import type { AdkEvent, AdkSession, ChatMessage, SourceItem } from './adk'
94

105
export interface ChatSessionState {
116
messages: Array<ChatMessage>
@@ -76,8 +71,8 @@ export async function startChatStream({
7671
for await (const event of stream) {
7772
processEventIntoCache(queryClient, sessionId, event)
7873
}
79-
} catch (err) {
80-
if (err instanceof Error && err.name === 'AbortError') {
74+
} catch (err: any) {
75+
if (err.name === 'AbortError' || err.message?.includes('aborted')) {
8176
// Expected when a stream is canceled
8277
return
8378
}
@@ -165,16 +160,17 @@ export function applyEventToState(
165160
// console.info('applyEventToState', event);
166161

167162
// Skip function responses
168-
const eventParts = event.content.parts.filter(p => !p.functionResponse);
163+
const eventParts = event.content.parts.filter((p) => !p.functionResponse)
169164

170165
if (event.content.role === 'user') {
171166
// Don't insert user message if it's just a function response.
172167
// We may store the map of function response as a separate map when we need tool response in UI.
173-
if (eventParts.length === 0) return prev;
168+
if (eventParts.length === 0) return prev
174169

175170
// event is user message, just append message
176171
return {
177-
...prev, messages: [
172+
...prev,
173+
messages: [
178174
...prev.messages,
179175
{
180176
id: genId(),
@@ -183,7 +179,7 @@ export function applyEventToState(
183179
parts: [...eventParts],
184180
timestamp: new Date(),
185181
},
186-
]
182+
],
187183
}
188184
}
189185

@@ -192,9 +188,11 @@ export function applyEventToState(
192188
// Agent parts (text & tool calls)
193189
if (event.content.role === 'model') {
194190
const last = messages[messages.length - 1]
195-
const isLastStillStreaming = last?.role === 'model' &&
196-
last?.isStreaming &&
197-
(last?.author || 'writer') === (event.author || 'writer')
191+
const isLastStillStreaming =
192+
last &&
193+
last.role === 'model' &&
194+
last.isStreaming &&
195+
(last.author || 'writer') === (event.author || 'writer')
198196

199197
if (!isLastStillStreaming) {
200198
messages = [
@@ -206,7 +204,9 @@ export function applyEventToState(
206204
parts: [...eventParts],
207205
isStreaming: event.partial !== false,
208206
timestamp: new Date(),
209-
langfuseTraceId: event.customMetadata?.['langfuse_trace_id'] as string | undefined,
207+
langfuseTraceId: event.customMetadata?.['langfuse_trace_id'] as
208+
| string
209+
| undefined,
210210
},
211211
]
212212
} else if (!event.partial) {
@@ -235,7 +235,7 @@ export function applyEventToState(
235235

236236
// If last part is not a text part, push the text part as a new part
237237
const lastPart = updatedParts[updatedParts.length - 1]
238-
if (lastPart?.text === undefined) {
238+
if (lastPart.text === undefined) {
239239
updatedParts.push({ ...part })
240240
continue
241241
}
@@ -253,13 +253,13 @@ export function applyEventToState(
253253
...last,
254254
parts: updatedParts,
255255
isStreaming: true,
256-
},
256+
} as ChatMessage,
257257
]
258258
}
259259
}
260260

261261
// Grounding metadata (Sources)
262-
let sources = prev.sources;
262+
let sources = prev.sources
263263
if (event.groundingMetadata?.groundingChunks) {
264264
const newSources: Array<SourceItem> =
265265
event.groundingMetadata.groundingChunks

src/routes/_app/index.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useCallback, useState } from 'react'
33
import { useQueryClient } from '@tanstack/react-query'
44
import { sendChatMessage } from '@/lib/chatCache'
55
import { createSession } from '@/lib/sessions.functions'
6-
import { ChatInput } from '@/components/ChatInput'
76

87
export const Route = createFileRoute('/_app/')({
98
component: LandingPage,
@@ -12,18 +11,22 @@ export const Route = createFileRoute('/_app/')({
1211
function LandingPage() {
1312
const navigate = useNavigate()
1413
const queryClient = useQueryClient()
14+
const [message, setMessage] = useState('')
1515
const [isLoading, setIsLoading] = useState(false)
1616
const [error, setError] = useState<string | null>(null)
1717

18-
const handleSend = useCallback(
19-
async (text: string) => {
20-
if (isLoading) return
18+
const handleSubmit = useCallback(
19+
async (e: React.FormEvent<HTMLFormElement>) => {
20+
e.preventDefault()
21+
if (!message.trim() || isLoading) return
2122

2223
setIsLoading(true)
2324
setError(null)
2425

26+
// Generate a new session ID
2527
const sessionId = crypto.randomUUID()
2628

29+
// 1. Create the session in ADK upfront
2730
try {
2831
await createSession({ data: sessionId })
2932
} catch (err) {
@@ -32,14 +35,17 @@ function LandingPage() {
3235
return
3336
}
3437

35-
sendChatMessage(queryClient, sessionId, text)
38+
// 2. Instantly seed the cache and start the background stream fetch
39+
sendChatMessage(queryClient, sessionId, message.trim())
3640

41+
// 3. Navigate to the session page.
42+
// The session page will simply subscribe to the cache, watching the letters stream in.
3743
navigate({
3844
to: '/session/$sessionId',
3945
params: { sessionId },
4046
})
4147
},
42-
[isLoading, navigate, queryClient],
48+
[message, isLoading, navigate, queryClient],
4349
)
4450

4551
return (
@@ -58,16 +64,37 @@ function LandingPage() {
5864
</div>
5965

6066
{/* Input */}
61-
<div className="max-w-2xl w-full mt-8">
62-
<ChatInput
63-
onSend={handleSend}
64-
disabled={isLoading}
65-
placeholder="貼上想查核的訊息,或輸入 Cofacts 文章連結 (https://cofacts.tw/article/...)..."
66-
/>
67+
<form onSubmit={handleSubmit} className="max-w-2xl w-full mt-8">
68+
<div className="relative rounded-xl shadow-sm border border-gray-300 bg-white focus-within:ring-2 focus-within:ring-primary focus-within:border-primary transition-all">
69+
<textarea
70+
value={message}
71+
onChange={(e) => setMessage(e.target.value)}
72+
className="w-full bg-transparent border-none focus:ring-0 p-4 pr-14 min-h-[100px] max-h-48 resize-none text-sm rounded-xl"
73+
placeholder="貼上想查核的訊息,或輸入 Cofacts 文章連結 (https://cofacts.tw/article/...)..."
74+
onKeyDown={(e) => {
75+
if (e.key === 'Enter' && !e.shiftKey) {
76+
e.preventDefault()
77+
e.currentTarget.form?.requestSubmit()
78+
}
79+
}}
80+
/>
81+
<button
82+
type="submit"
83+
disabled={!message.trim() || isLoading}
84+
className="absolute right-3 bottom-3 p-2 bg-primary text-black rounded-lg hover:bg-primary-hover transition-colors flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
85+
>
86+
<span className="material-symbols-outlined text-lg">send</span>
87+
</button>
88+
</div>
6789
{error && (
6890
<div className="mt-2 text-sm text-red-500 text-center">{error}</div>
6991
)}
70-
</div>
92+
<div className="text-center mt-3">
93+
<span className="text-[10px] text-gray-400">
94+
AI 可能會犯錯,請務必查核事實。
95+
</span>
96+
</div>
97+
</form>
7198
</div>
7299
)
73100
}

0 commit comments

Comments
 (0)