From e7da2fe7886e5977fa79286a5d837aca12d6d8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 16 Jul 2026 17:51:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=20=E4=BC=9A=E8=AF=9D=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=93=8D=E5=BA=94=E4=B9=B1=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 1 + .../MessageBubble.test.tsx | 53 +++++++++++ .../useEditorAgentConversation.test.tsx | 88 +++++++++++++++++++ .../useEditorAgentConversation.ts | 19 ++-- 4 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index aa00c6f9e..526d9c791 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -60,6 +60,7 @@ - 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。 - 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 +- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。 - 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 ## 附件 diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx new file mode 100644 index 000000000..257892551 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EditorAgentMessage } from '@/packages/shared/src/contracts'; + +import { MessageBubble } from './MessageBubble.tsx'; + +function renderMessage(message: EditorAgentMessage) { + return render( + , + ); +} + +describe('MessageBubble', () => { + it('shows prefixed system errors as red Agent errors without the wire prefix', () => { + renderMessage({ + id: 2, + role: 'system', + text: 'ERROR planning failed', + attachments: [], + toolCall: null, + createdAt: '2026-07-16T00:00:00Z', + }); + + const error = screen.getByLabelText('Agent错误'); + expect(error.textContent).toContain('planning failed'); + expect(error.textContent).not.toContain('ERROR'); + expect(error.firstElementChild?.classList.contains('bg-red-50')).toBe(true); + expect(error.firstElementChild?.classList.contains('text-red-700')).toBe( + true, + ); + }); + + it('continues to hide internal system messages without the error prefix', () => { + const { container } = renderMessage({ + id: 3, + role: 'system', + text: 'internal attachment bookkeeping', + attachments: [], + toolCall: null, + createdAt: '2026-07-16T00:00:00Z', + }); + + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 45d4b07ae..f7e8730d4 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -4,6 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { + EditorAgentConversationDetail, EditorAgentMessage, EditorAgentMessageResponse, } from '../../../../packages/shared/src/contracts/editorAgent.ts'; @@ -173,6 +174,93 @@ describe('useEditorAgentConversation', () => { ); }); + it('keeps the latest conversation when detail responses arrive out of order', async () => { + const client = createClient(); + let resolveConversation2!: (detail: EditorAgentConversationDetail) => void; + let resolveConversation3!: (detail: EditorAgentConversationDetail) => void; + const conversation2Promise = new Promise( + (resolve) => { + resolveConversation2 = resolve; + }, + ); + const conversation3Promise = new Promise( + (resolve) => { + resolveConversation3 = resolve; + }, + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.activeConversationId).toBe('conversation-1'); + }); + vi.mocked(client.getConversation).mockImplementation((conversationId) => { + if (conversationId === 'conversation-2') { + return conversation2Promise; + } + if (conversationId === 'conversation-3') { + return conversation3Promise; + } + throw new Error(`Unexpected conversation: ${conversationId}`); + }); + + let conversation2Load!: Promise; + let conversation3Load!: Promise; + act(() => { + conversation2Load = result.current.selectConversation('conversation-2'); + conversation3Load = result.current.selectConversation('conversation-3'); + }); + + await act(async () => { + resolveConversation3({ + conversationId: 'conversation-3', + projectId: 'project-1', + title: '第三个会话', + messages: [ + { + id: 3, + role: 'assistant', + text: '第三个会话消息', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:03:00.000Z', + }, + ], + createdAt: '2026-07-03T00:03:00.000Z', + updatedAt: '2026-07-03T00:03:00.000Z', + }); + await conversation3Load; + }); + expect(result.current.activeConversationId).toBe('conversation-3'); + expect(result.current.messages[0]?.text).toBe('第三个会话消息'); + expect(result.current.isLoadingMessages).toBe(false); + + await act(async () => { + resolveConversation2({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '第二个会话', + messages: [ + { + id: 2, + role: 'assistant', + text: '第二个会话消息', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:02:00.000Z', + }, + ], + createdAt: '2026-07-03T00:02:00.000Z', + updatedAt: '2026-07-03T00:02:00.000Z', + }); + await conversation2Load; + }); + expect(result.current.activeConversationId).toBe('conversation-3'); + expect(result.current.messages[0]?.text).toBe('第三个会话消息'); + expect(result.current.isLoadingMessages).toBe(false); + }); + it('appends a lazily reconciled tool message delta', async () => { const client = createClient(); const pendingMessage: EditorAgentMessage = { diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index ed18503ad..747dbf69e 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -170,6 +170,7 @@ export function useEditorAgentConversation({ const activeRequestAbortControllerRef = useRef(null); const activeConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); + const conversationLoadRequestIdRef = useRef(0); useEffect(() => { activeConversationIdRef.current = activeConversationId; @@ -205,6 +206,8 @@ export function useEditorAgentConversation({ conversationId: string, options: { showLoading?: boolean } = {}, ) => { + const requestId = conversationLoadRequestIdRef.current + 1; + conversationLoadRequestIdRef.current = requestId; const showLoading = options.showLoading ?? true; if (showLoading) { setIsLoadingMessages(true); @@ -212,15 +215,19 @@ export function useEditorAgentConversation({ setErrorMessage(null); try { const detail = await client.getConversation(conversationId); - applyConversationDetail(detail); + if (conversationLoadRequestIdRef.current === requestId) { + applyConversationDetail(detail); + } return detail; } catch (error) { - setErrorMessage( - error instanceof Error ? error.message : '读取画布 Agent 会话失败', - ); + if (conversationLoadRequestIdRef.current === requestId) { + setErrorMessage( + error instanceof Error ? error.message : '读取画布 Agent 会话失败', + ); + } throw error; } finally { - if (showLoading) { + if (conversationLoadRequestIdRef.current === requestId) { setIsLoadingMessages(false); } } @@ -229,6 +236,8 @@ export function useEditorAgentConversation({ ); useEffect(() => { + conversationLoadRequestIdRef.current += 1; + setIsLoadingMessages(false); if (!normalizedProjectId) { setConversations([]); setActiveConversationId(null);