diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 70c00e352..8177e4a8b 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -62,7 +62,7 @@ - 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 - 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。 -- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。用户主动停止后不显示发送失败、不重试 POST、不删除已写入 OSS 的用户消息;停止后的静默会话重读直接执行在 abort 分支内,`isAborting` 一直保持到 GET 结束,因此面板继续显示“停止中”,并由未结束的 `isWaiting` 阻止下一次发送。该阶段不显示“刷新中”或额外 loading;GET 结束后一次性退出“停止中”并恢复发送。 +- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。整轮发送从首次创建会话前就必须注册为可停止;若用户在新会话创建完成前停止,允许创建请求正常收口,但创建完成后不得继续发送规划请求。用户主动停止后不显示发送失败、不重试 POST、不删除已写入 OSS 的用户消息;停止后的静默会话重读直接执行在 abort 分支内,`isAborting` 一直保持到 GET 结束,因此面板继续显示“停止中”,并由未结束的 `isWaiting` 阻止下一次发送。该阶段不显示“刷新中”或额外 loading;GET 结束后一次性退出“停止中”并恢复发送。 - 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 ## 附件 @@ -117,7 +117,7 @@ 4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层); 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); -7. 普通消息请求等待期间将“发送”切换为“停止”;停止通过 `AbortController` 取消 HTTP 请求并释放后端会话锁,不触发 POST transport retry,已持久化的用户消息保持为普通历史。未停止且超过 120 秒时,思考气泡显示“仍在处理中,请耐心等待”。 +7. 普通消息请求等待期间将“发送”切换为“停止”;整轮发送在首次创建会话前即绑定 `AbortController`,创建完成前停止时不得继续发送规划请求,规划请求已发出时则取消 HTTP 请求并释放后端会话锁,不触发 POST transport retry,已持久化的用户消息保持为普通历史。未停止且超过 120 秒时,思考气泡显示“仍在处理中,请耐心等待”。 8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路; 9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。 diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 7f0ed017e..d4a4eeea6 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -450,6 +450,98 @@ describe('useEditorAgentConversation', () => { ); }); + it('stops the first send while conversation creation is pending', async () => { + const client = createClient(); + let resolveCreate!: (detail: EditorAgentConversationDetail) => void; + vi.mocked(client.listConversations).mockResolvedValueOnce([]); + vi.mocked(client.createConversation).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.isLoadingConversations).toBe(false); + }); + + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage('首次创建时停止'); + }); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + + expect(result.current.isWaiting).toBe(true); + act(() => { + result.current.stopCurrentTurn(); + }); + expect(result.current.isAborting).toBe(true); + + await act(async () => { + resolveCreate({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '新对话', + messages: [], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:01:00.000Z', + }); + await sendPromise; + }); + + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(result.current.activeConversationId).toBe('conversation-2'); + expect(result.current.messages).toEqual([]); + expect(result.current.isWaiting).toBe(false); + expect(result.current.isAborting).toBe(false); + }); + + it('keeps a stopped first send silent when conversation creation fails', async () => { + const client = createClient(); + let rejectCreate!: (error: Error) => void; + vi.mocked(client.listConversations).mockResolvedValueOnce([]); + vi.mocked(client.createConversation).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectCreate = reject; + }), + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.isLoadingConversations).toBe(false); + }); + + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage('首次创建失败前停止'); + }); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + + act(() => { + result.current.stopCurrentTurn(); + }); + + await act(async () => { + rejectCreate(new Error('创建会话失败')); + await sendPromise; + }); + + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(result.current.errorMessage).toBeNull(); + expect(result.current.isWaiting).toBe(false); + expect(result.current.isAborting).toBe(false); + }); + it('does not send or apply a stale conversation created after switching projects', async () => { const client = createClient(); let resolveCreate!: (detail: EditorAgentConversationDetail) => void; diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index f935fb828..4fb4609ae 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -10,7 +10,6 @@ import type { EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; -import { isAbortError } from '../../../services/apiClient.ts'; import { cancelEditorAgentToolCall, confirmEditorAgentToolCall, @@ -61,8 +60,6 @@ export type EditorAgentToolCallActionState = { type ActiveEditorAgentSend = { requestId: number; - conversationId: string; - clientMessageId: string; controller: AbortController; }; @@ -447,7 +444,12 @@ export function useEditorAgentConversation({ isWaitingRef.current = true; const requestedProjectId = normalizedProjectId; const requestId = pendingSendRequestIdRef.current + 1; + const controller = new AbortController(); pendingSendRequestIdRef.current = requestId; + activeSendRef.current = { + requestId, + controller, + }; setErrorMessage(null); setIsWaiting(true); setPatienceNoticeConversationId(null); @@ -458,12 +460,12 @@ export function useEditorAgentConversation({ conversationId = await ensureConversationForSend(); if ( pendingSendRequestIdRef.current !== requestId || - normalizedProjectIdRef.current !== requestedProjectId + normalizedProjectIdRef.current !== requestedProjectId || + controller.signal.aborted ) { return; } const clientMessageId = createEditorAgentClientMessageId(); - const controller = new AbortController(); const nextOptimisticMessage = createLocalUserMessage({ id: -1, clientMessageId, @@ -471,12 +473,6 @@ export function useEditorAgentConversation({ attachments, }); optimisticMessage = nextOptimisticMessage; - activeSendRef.current = { - requestId, - conversationId, - clientMessageId, - controller, - }; setMessages((currentMessages) => [ ...currentMessages, nextOptimisticMessage, @@ -516,8 +512,10 @@ export function useEditorAgentConversation({ } } catch (error) { const wasStoppedByUser = - stoppedSendRequestIdRef.current === requestId && isAbortError(error); + stoppedSendRequestIdRef.current === requestId && + controller.signal.aborted; if (wasStoppedByUser) { + setErrorMessage(null); if ( conversationId && activeConversationIdRef.current === conversationId