diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 3072fa16c..fabc46088 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -11,6 +11,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { + EditorAgentConversationDetail, EditorAgentMessage, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; @@ -315,7 +316,7 @@ describe('EditorAgentConversationPanelView', () => { ); }); - it('disables sending while a message request is pending without showing stop', async () => { + it('replaces send with interrupt while a message request is pending', async () => { const client = createClient(); let resolveSend!: (response: EditorAgentMessageResponse) => void; vi.mocked(client.sendMessage).mockImplementation( @@ -346,10 +347,8 @@ describe('EditorAgentConversationPanelView', () => { await act(async () => { await Promise.resolve(); }); - expect( - screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), - ).toBe(true); - expect(screen.queryByRole('button', { name: '停止' })).toBeNull(); + expect(screen.queryByRole('button', { name: '发送' })).toBeNull(); + expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(); expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); act(() => { @@ -373,6 +372,77 @@ describe('EditorAgentConversationPanelView', () => { expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); }); + it('keeps post-abort refresh inside the stopping state', async () => { + const client = createClient(); + let rejectSend!: (error: unknown) => void; + const capturedRequest: { signal: AbortSignal | null } = { signal: null }; + vi.mocked(client.sendMessage).mockImplementationOnce( + (_conversationId, _payload, options) => + new Promise((_resolve, reject) => { + rejectSend = reject; + capturedRequest.signal = options.signal ?? null; + }), + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + let resolveRefresh!: (detail: EditorAgentConversationDetail) => void; + vi.mocked(client.getConversation).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '请中断这一轮' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + const interruptButton = await screen.findByRole('button', { + name: '停止', + }); + fireEvent.click(interruptButton); + expect(capturedRequest.signal?.aborted).toBe(true); + expect(screen.getByRole('button', { name: '停止中' })).toBeTruthy(); + + await act(async () => { + rejectSend( + capturedRequest.signal?.reason ?? + new DOMException('aborted', 'AbortError'), + ); + await Promise.resolve(); + }); + + expect(screen.getByRole('button', { name: '停止中' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '发送' })).toBeNull(); + expect(screen.queryByText('刷新中')).toBeNull(); + expect(screen.queryByText('加载中')).toBeNull(); + expect(screen.getByText('请中断这一轮')).toBeTruthy(); + + await act(async () => { + resolveRefresh({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:20.000Z', + }); + await Promise.resolve(); + }); + expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy(); + expect(screen.queryByText('刷新中')).toBeNull(); + }); + it('uploads pasted images as canvas attachments before sending', async () => { const client = createClient(); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 073da2c3b..b8545da9e 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -5,6 +5,7 @@ import { Paperclip, Plus, Send, + Square, Trash2, X, } from 'lucide-react'; @@ -258,6 +259,7 @@ export function EditorAgentConversationPanelView({ isCreatingConversation, isDeletingConversation, isWaiting, + isAborting, isPatienceNoticeVisible, toolCallAction, isToolCallActionPending, @@ -266,6 +268,7 @@ export function EditorAgentConversationPanelView({ selectConversation, refreshActiveConversation, sendMessage, + stopCurrentTurn, confirmToolCall, cancelToolCall, deleteActiveConversation, @@ -582,7 +585,9 @@ export function EditorAgentConversationPanelView({ ? '执行中' : toolCallAction?.action === 'cancel' ? '取消中' - : '思考中'} + : isAborting + ? '停止中' + : '思考中'} ) : null} @@ -673,19 +678,38 @@ export function EditorAgentConversationPanelView({ } }} /> - + {isWaiting ? ( + + ) : ( + + )} diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 3df8d34ea..7f0ed017e 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -149,7 +149,7 @@ describe('useEditorAgentConversation', () => { text: '把这个角色改成像素风', attachments: [], }), - {}, + expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(result.current.isWaiting).toBe(false); expect(result.current.activeConversation?.title).toBe( @@ -969,15 +969,17 @@ describe('useEditorAgentConversation', () => { expect(vi.getTimerCount()).toBe(0); }); - it('keeps the active request pending without exposing a stop action', async () => { + it('aborts the active request, keeps the user message, and allows a new prompt', async () => { const client = createClient(); - let capturedSignal: AbortSignal | null = null; - let resolveSend!: (response: EditorAgentMessageResponse) => void; - vi.mocked(client.sendMessage).mockImplementation( - (_conversationId, _payload, options) => - new Promise((resolve) => { - resolveSend = resolve; - capturedSignal = options.signal ?? null; + const capturedRequest: { signal: AbortSignal | null } = { signal: null }; + let capturedClientMessageId = ''; + let rejectSend!: (error: unknown) => void; + vi.mocked(client.sendMessage).mockImplementationOnce( + (_conversationId, payload, options) => + new Promise((_resolve, reject) => { + capturedClientMessageId = payload.clientMessageId; + rejectSend = reject; + capturedRequest.signal = options.signal ?? null; }), ); const { result } = renderHook(() => @@ -990,42 +992,95 @@ describe('useEditorAgentConversation', () => { ); }); - vi.useFakeTimers(); let sendPromise!: Promise; act(() => { sendPromise = result.current.sendMessage('请继续'); void result.current.sendMessage('不要重复发送'); }); + await waitFor(() => { + expect(capturedRequest.signal).not.toBeNull(); + }); + + expect(result.current.isWaiting).toBe(true); + expect(client.sendMessage).toHaveBeenCalledTimes(1); + expect(capturedRequest.signal?.aborted).toBe(false); + expect(typeof result.current.stopCurrentTurn).toBe('function'); + + act(() => { + result.current.stopCurrentTurn(); + }); + expect(capturedRequest.signal?.aborted).toBe(true); + expect(result.current.isAborting).toBe(true); + + let resolveRefresh!: (detail: EditorAgentConversationDetail) => void; + vi.mocked(client.getConversation).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + await act(async () => { + rejectSend( + capturedRequest.signal?.reason ?? + new DOMException('aborted', 'AbortError'), + ); await Promise.resolve(); }); expect(result.current.isWaiting).toBe(true); - expect(result.current.isPatienceNoticeVisible).toBe(false); - expect(client.sendMessage).toHaveBeenCalledTimes(1); - expect(capturedSignal).toBeNull(); - expect('stopCurrentTurn' in result.current).toBe(false); - - act(() => { - vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); - }); - expect(result.current.isPatienceNoticeVisible).toBe(true); + expect(result.current.isAborting).toBe(true); + expect(result.current.isLoadingMessages).toBe(false); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.messages).toEqual([ + expect.objectContaining({ + clientMessageId: capturedClientMessageId, + role: 'user', + text: '请继续', + }), + ]); await act(async () => { - resolveSend({ - conversation: { - conversationId: 'conversation-1', - projectId: 'project-1', - title: '角色参考', - updatedAt: '2026-07-03T00:00:20.000Z', - }, - deltaMessages: [], - errorMessage: null, + await result.current.sendMessage('刷新完成前不应发送'); + }); + expect(client.sendMessage).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveRefresh({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [ + { + id: 1, + clientMessageId: capturedClientMessageId, + role: 'user', + text: '请继续', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:00:20.000Z', + }, + ], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:20.000Z', }); await sendPromise; }); - + expect(result.current.isLoadingMessages).toBe(false); expect(result.current.isWaiting).toBe(false); - expect(result.current.isPatienceNoticeVisible).toBe(false); + expect(result.current.isAborting).toBe(false); + + await act(async () => { + await result.current.sendMessage('新的请求'); + }); + expect(client.sendMessage).toHaveBeenCalledTimes(2); + expect(client.sendMessage).toHaveBeenLastCalledWith( + 'conversation-1', + expect.objectContaining({ + clientMessageId: expect.not.stringMatching(capturedClientMessageId), + text: '新的请求', + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); }); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 765ba77bf..1403ad609 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -20,6 +20,7 @@ import { sendEditorAgentMessage, type SendEditorAgentMessageOptions, } from '../../../services/image-editor/editorAgentClient.ts'; +import { isAbortError } from '../../../services/apiClient.ts'; export type EditorAgentConversationClient = { listConversations: ( @@ -58,6 +59,13 @@ export type EditorAgentToolCallActionState = { action: EditorAgentToolCallAction; } | null; +type ActiveEditorAgentSend = { + requestId: number; + conversationId: string; + clientMessageId: string; + controller: AbortController; +}; + const defaultEditorAgentConversationClient: EditorAgentConversationClient = { listConversations: listEditorAgentConversations, createConversation: createEditorAgentConversation, @@ -151,6 +159,7 @@ export function useEditorAgentConversation({ const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [isWaiting, setIsWaiting] = useState(false); + const [isAborting, setIsAborting] = useState(false); const [patienceNoticeConversationId, setPatienceNoticeConversationId] = useState(null); const [toolCallAction, setToolCallAction] = @@ -163,6 +172,8 @@ export function useEditorAgentConversation({ const createConversationRequestIdRef = useRef(0); const isWaitingRef = useRef(false); const pendingSendRequestIdRef = useRef(0); + const activeSendRef = useRef(null); + const stoppedSendRequestIdRef = useRef(null); const patienceNoticeTimerRef = useRef | null>( null, ); @@ -177,6 +188,8 @@ export function useEditorAgentConversation({ pendingSendRequestIdRef.current += 1; createConversationRequestIdRef.current += 1; isWaitingRef.current = false; + activeSendRef.current = null; + stoppedSendRequestIdRef.current = null; if (patienceNoticeTimerRef.current !== null) { clearTimeout(patienceNoticeTimerRef.current); patienceNoticeTimerRef.current = null; @@ -188,11 +201,14 @@ export function useEditorAgentConversation({ pendingSendRequestIdRef.current += 1; createConversationRequestIdRef.current += 1; isWaitingRef.current = false; + activeSendRef.current = null; + stoppedSendRequestIdRef.current = null; if (patienceNoticeTimerRef.current !== null) { clearTimeout(patienceNoticeTimerRef.current); patienceNoticeTimerRef.current = null; } setIsWaiting(false); + setIsAborting(false); setIsCreatingConversation(false); setPatienceNoticeConversationId(null); }, [normalizedProjectId]); @@ -224,14 +240,20 @@ export function useEditorAgentConversation({ ); const loadConversation = useCallback( - async (conversationId: string, options: { showLoading?: boolean } = {}) => { + async ( + conversationId: string, + options: { showLoading?: boolean; reportError?: boolean } = {}, + ) => { const requestId = conversationLoadRequestIdRef.current + 1; conversationLoadRequestIdRef.current = requestId; const showLoading = options.showLoading ?? true; + const reportError = options.reportError ?? true; if (showLoading) { setIsLoadingMessages(true); } - setErrorMessage(null); + if (reportError) { + setErrorMessage(null); + } try { const detail = await client.getConversation(conversationId); if (conversationLoadRequestIdRef.current === requestId) { @@ -239,14 +261,20 @@ export function useEditorAgentConversation({ } return detail; } catch (error) { - if (conversationLoadRequestIdRef.current === requestId) { + if ( + reportError && + conversationLoadRequestIdRef.current === requestId + ) { setErrorMessage( error instanceof Error ? error.message : '读取画布 Agent 会话失败', ); } throw error; } finally { - if (conversationLoadRequestIdRef.current === requestId) { + if ( + showLoading && + conversationLoadRequestIdRef.current === requestId + ) { setIsLoadingMessages(false); } } @@ -435,6 +463,7 @@ export function useEditorAgentConversation({ return; } const clientMessageId = createEditorAgentClientMessageId(); + const controller = new AbortController(); const nextOptimisticMessage = createLocalUserMessage({ id: -1, clientMessageId, @@ -442,6 +471,12 @@ export function useEditorAgentConversation({ attachments, }); optimisticMessage = nextOptimisticMessage; + activeSendRef.current = { + requestId, + conversationId, + clientMessageId, + controller, + }; setMessages((currentMessages) => [ ...currentMessages, nextOptimisticMessage, @@ -459,7 +494,7 @@ export function useEditorAgentConversation({ text, attachments, }, - {}, + { signal: controller.signal }, ); if (pendingSendRequestIdRef.current !== requestId) { @@ -480,6 +515,20 @@ export function useEditorAgentConversation({ applyDeltaMessages(response.deltaMessages); } } catch (error) { + const wasStoppedByUser = + stoppedSendRequestIdRef.current === requestId && isAbortError(error); + if (wasStoppedByUser) { + if ( + conversationId && + activeConversationIdRef.current === conversationId + ) { + await loadConversation(conversationId, { + showLoading: false, + reportError: false, + }).catch(() => undefined); + } + return; + } const message = error instanceof Error ? error.message : '发送画布 Agent 消息失败'; const shouldReportError = @@ -504,7 +553,14 @@ export function useEditorAgentConversation({ patienceNoticeTimerRef.current = null; } isWaitingRef.current = false; + if (activeSendRef.current?.requestId === requestId) { + activeSendRef.current = null; + } + if (stoppedSendRequestIdRef.current === requestId) { + stoppedSendRequestIdRef.current = null; + } setIsWaiting(false); + setIsAborting(false); setPatienceNoticeConversationId(null); } } @@ -515,10 +571,21 @@ export function useEditorAgentConversation({ applyDeltaMessages, isLoadingConversations, isLoadingMessages, + loadConversation, normalizedProjectId, ], ); + const stopCurrentTurn = useCallback(() => { + const activeSend = activeSendRef.current; + if (!activeSend || activeSend.controller.signal.aborted) { + return; + } + stoppedSendRequestIdRef.current = activeSend.requestId; + setIsAborting(true); + activeSend.controller.abort(); + }, []); + const resolveToolCall = useCallback( async (messageId: number, action: EditorAgentToolCallAction) => { const conversationId = activeConversationIdRef.current; @@ -622,6 +689,7 @@ export function useEditorAgentConversation({ isCreatingConversation, isDeletingConversation, isWaiting, + isAborting, isPatienceNoticeVisible: isWaiting && patienceNoticeConversationId === activeConversationId, toolCallAction, @@ -631,6 +699,7 @@ export function useEditorAgentConversation({ selectConversation, refreshActiveConversation, sendMessage, + stopCurrentTurn, confirmToolCall, cancelToolCall, deleteActiveConversation, diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index d411171bf..e24d02164 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -6,6 +6,7 @@ import { clearStoredAccessToken, fetchWithApiAuth, getStoredAccessToken, + isAbortError, isTimeoutError, refreshStoredAccessToken, requestJson, @@ -652,6 +653,55 @@ describe('apiClient', () => { ); }); + it('does not retry a caller-aborted unsafe request', async () => { + setStoredAccessToken('editor-agent-token', { emit: false }); + const controller = new AbortController(); + fetchMock.mockImplementation( + async (_input: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + const request = requestJson( + '/api/editor/agent-conversations/conversation-1/messages', + { + method: 'POST', + signal: controller.signal, + body: JSON.stringify({ + clientMessageId: 'client-message-aborted', + text: '中断这一轮', + }), + }, + '发送画布 Agent 消息失败', + { + authImpact: 'local', + retry: { + maxRetries: 1, + baseDelayMs: 1, + maxDelayMs: 1, + retryUnsafeMethods: true, + }, + }, + ); + await Promise.resolve(); + controller.abort(); + + let capturedError: unknown; + try { + await request; + } catch (error) { + capturedError = error; + } + + expect(isAbortError(capturedError)).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('aborts requests when timeoutMs is reached', async () => { setStoredAccessToken('timeout-token', { emit: false }); fetchMock.mockImplementation( diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1c19e8f16..baf25c6c0 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -405,11 +405,10 @@ function shouldRetryResponse( export function isAbortError(error: unknown) { return ( - error instanceof Error && - (error.name === 'AbortError' || - (typeof DOMException !== 'undefined' && - error instanceof DOMException && - error.name === 'AbortError')) + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError' ); } diff --git a/src/services/image-editor/editorAgentClient.test.ts b/src/services/image-editor/editorAgentClient.test.ts index 98b6f785a..c6ad10083 100644 --- a/src/services/image-editor/editorAgentClient.test.ts +++ b/src/services/image-editor/editorAgentClient.test.ts @@ -121,12 +121,17 @@ describe('editorAgentClient', () => { errorMessage: null, }; requestJsonMock.mockResolvedValueOnce(responseBody); + const controller = new AbortController(); - const result = await sendEditorAgentMessage('conversation-1', { - clientMessageId: 'client-message-1', - text: '帮我把角色改成像素风', - attachments: [], - }); + const result = await sendEditorAgentMessage( + 'conversation-1', + { + clientMessageId: 'client-message-1', + text: '帮我把角色改成像素风', + attachments: [], + }, + { signal: controller.signal }, + ); expect(result).toEqual(responseBody); expect(requestJsonMock).toHaveBeenCalledWith( @@ -139,6 +144,7 @@ describe('editorAgentClient', () => { text: '帮我把角色改成像素风', attachments: [], }), + signal: controller.signal, }), '发送画布 Agent 消息失败', expect.objectContaining({