diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index b3326b4cb..724237e0a 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -10,6 +10,10 @@ import { } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + EditorAgentMessage, + EditorAgentMessageResponse, +} from '@/packages/shared/src/contracts'; import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts'; @@ -19,24 +23,24 @@ const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn()); -vi.mock('../../services/image-editor/editorProjectClient', async () => { +vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => { const actual = await vi.importActual< typeof import('../../../services/image-editor/editorProjectClient.ts') - >('../../services/image-editor/editorProjectClient'); + >('@/src/services/image-editor/editorProjectClient.ts'); return { ...actual, createEditorProjectResource: createEditorProjectResourceMock, }; }); -vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ +vi.mock('@/src/services/image-editor/editorMediaAssetUploadClient.ts', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); -vi.mock('./ImageCanvasFileModel', async () => { - const actual = await vi.importActual( - './ImageCanvasFileModel', - ); +vi.mock('@/src/components/image-editor/ImageCanvasFileModel.ts', async () => { + const actual = await vi.importActual< + typeof import('../ImageCanvasFileModel.ts') + >('@/src/components/image-editor/ImageCanvasFileModel.ts'); return { ...actual, probeImageFileDimensions: probeImageFileDimensionsMock, @@ -49,8 +53,6 @@ function createClient(): EditorAgentConversationClient { { conversationId: 'conversation-1', projectId: 'project-1', - title: '角色参考', - createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }, ]), @@ -68,13 +70,11 @@ function createClient(): EditorAgentConversationClient { title: '角色参考', messages: [ { - id: 'assistant-old', + id: 1, role: 'assistant', - kind: 'chat', text: '已经看到画布内容', attachments: [], - generations: [], - status: 'completed', + toolCall: null, createdAt: '2026-07-03T00:00:10.000Z', }, ], @@ -89,28 +89,43 @@ function createClient(): EditorAgentConversationClient { createdAt: '2026-07-03T00:00:00.000Z', updatedAt: '2026-07-03T00:00:00.000Z', }), - streamMessage: vi - .fn() - .mockImplementation(async (_conversationId, _payload, options) => { - options.onEvent?.({ - event: 'stage', - data: { conversationId: _conversationId, stage: 'responding' }, - }); - options.onEvent?.({ - event: 'message_delta', - data: { - conversationId: _conversationId, - messageId: 'assistant-stream', - role: 'assistant', - kind: 'chat', - textDelta: '收到,我会参考这张图。', - }, - }); - options.onEvent?.({ - event: 'done', - data: { conversationId: _conversationId, title: null }, - }); - }), + sendMessage: vi.fn().mockResolvedValue({ + deltaMessages: [ + { + id: 2, + role: 'assistant', + text: '收到,我会参考这张图。', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:00:00.000Z', + }, + ], + errorMessage: null, + } as EditorAgentMessageResponse), + confirmToolCall: vi.fn(), + cancelToolCall: vi.fn(), + }; +} + +function createPendingToolCallMessage(): EditorAgentMessage { + return { + id: 2, + role: 'system', + text: 'internal system prompt that must stay hidden', + attachments: [], + toolCall: { + toolName: 'edit-image', + summary: '', + status: 'pending_confirmation', + args: { + object_image_id: 'source-image-1', + reference_image_ids: ['reference-image-1', 'reference-image-2'], + prompt: '把角色换成像素风', + }, + images: [], + error: null, + }, + createdAt: '2026-07-10T00:00:00.000Z', }; } @@ -120,7 +135,8 @@ describe('EditorAgentConversationPanelView', () => { uploadEditorMediaAssetFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: '/generated/pasted.png', - objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + objectKey: + 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', legacyPublicPath: '/generated/pasted.png', }); @@ -130,7 +146,8 @@ describe('EditorAgentConversationPanelView', () => { createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-pasted', imageSrc: '/generated/pasted.png', - objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + objectKey: + 'generated-character-drafts/editor/agent-paste/image/pasted.png', label: '粘贴图片', width: 320, height: 240, @@ -227,7 +244,7 @@ describe('EditorAgentConversationPanelView', () => { await waitFor(() => { expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy(); }); - expect(client.streamMessage).toHaveBeenCalledWith( + expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-2', expect.objectContaining({ text: '参考附件做像素风', @@ -282,7 +299,8 @@ describe('EditorAgentConversationPanelView', () => { 'project-1', expect.objectContaining({ imageSrc: '/generated/pasted.png', - objectKey: 'generated-character-drafts/editor/agent-paste/image/pasted.png', + objectKey: + 'generated-character-drafts/editor/agent-paste/image/pasted.png', assetObjectId: 'asset-object-pasted', width: 320, height: 240, @@ -296,7 +314,7 @@ describe('EditorAgentConversationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { - expect(client.streamMessage).toHaveBeenCalledWith( + expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '', @@ -361,7 +379,7 @@ describe('EditorAgentConversationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '发送' })); await waitFor(() => { - expect(client.streamMessage).toHaveBeenCalledWith( + expect(client.sendMessage).toHaveBeenCalledWith( 'conversation-1', expect.objectContaining({ text: '', @@ -377,41 +395,21 @@ describe('EditorAgentConversationPanelView', () => { }); }); - it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => { + it('preserves messages when the panel is collapsed and reopened', async () => { const client = createClient(); - let finishStream = () => {}; - vi.mocked(client.streamMessage).mockImplementation( - async (conversationId, _payload, options) => { - options.onEvent?.({ - event: 'message_delta', - data: { - conversationId, - messageId: 'assistant-stream', - role: 'assistant', - kind: 'chat', - textDelta: '我来生成图片。', - }, - }); - options.onEvent?.({ - event: 'stage', - data: { conversationId, stage: 'generating' }, - }); - options.onEvent?.({ - event: 'tool_started', - data: { - conversationId, - messageId: 'assistant-stream', - toolCallId: 'tool-call-generating', - toolName: 'generate_image', - taskId: 'task-generating', - model: 'gpt-image-2', - }, - }); - await new Promise((resolve) => { - finishStream = resolve; - }); - }, - ); + vi.mocked(client.sendMessage).mockResolvedValue({ + deltaMessages: [ + { + id: 3, + role: 'assistant', + text: '我来生成图片。', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:00:00.000Z', + }, + ], + errorMessage: null, + } as EditorAgentMessageResponse); const { rerender } = render( { await waitFor(() => { expect(screen.getByText('我来生成图片。')).toBeTruthy(); }); - expect(screen.getByText('生成中')).toBeTruthy(); - expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(); rerender( { />, ); + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); expect(screen.getByText('我来生成图片。')).toBeTruthy(); - expect(screen.getByText('生成中')).toBeTruthy(); - expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(); + }); + + it('shows structured pending details, hides system text and confirms once', async () => { + const client = createClient(); + const pendingMessage = createPendingToolCallMessage(); + vi.mocked(client.getConversation).mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [pendingMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }); + let resolveConfirmation: (message: EditorAgentMessage) => void = () => {}; + vi.mocked(client.confirmToolCall).mockImplementation( + () => + new Promise((resolve) => { + resolveConfirmation = resolve; + }), + ); + const onCanvasRefreshRequested = vi.fn(); + + render( + , + ); + + expect(await screen.findByText('把角色换成像素风')).toBeTruthy(); + expect(screen.getByText('source-image-1')).toBeTruthy(); + expect(screen.getByText('2 张')).toBeTruthy(); + expect( + screen.queryByText('internal system prompt that must stay hidden'), + ).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(client.confirmToolCall).toHaveBeenCalledWith('conversation-1', 2); + }); + expect( + (screen.getByRole('button', { name: '执行中' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '取消' }) as HTMLButtonElement) + .disabled, + ).toBe(true); await act(async () => { - finishStream(); + resolveConfirmation({ + ...pendingMessage, + text: 'internal completed tool output that must stay hidden', + toolCall: { + ...pendingMessage.toolCall!, + status: 'completed', + images: [ + { + resourceId: null, + objectKey: 'generated/result.png', + imageSrc: '/result.png', + thumbnailSrc: null, + width: 512, + height: 512, + }, + ], + }, + }); }); + + expect(await screen.findByText('已完成')).toBeTruthy(); + expect( + screen.queryByText( + 'internal completed tool output that must stay hidden', + ), + ).toBeNull(); + expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending tool call without exposing its system text', async () => { + const client = createClient(); + const pendingMessage = createPendingToolCallMessage(); + vi.mocked(client.getConversation).mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [pendingMessage], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }); + vi.mocked(client.cancelToolCall).mockResolvedValue({ + ...pendingMessage, + text: 'internal cancelled tool output that must stay hidden', + toolCall: { + ...pendingMessage.toolCall!, + status: 'cancelled', + }, + }); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '取消' })); + + expect(await screen.findByText('已取消')).toBeTruthy(); + expect(client.cancelToolCall).toHaveBeenCalledWith('conversation-1', 2); + expect( + screen.queryByText( + 'internal cancelled tool output that must stay hidden', + ), + ).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); it('keeps wheel scrolling inside the message history and input', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 9c6f96516..1207c2449 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -1,6 +1,5 @@ import { Bot, - Image as ImageIcon, Loader2, MessageCircle, Paperclip, @@ -22,8 +21,6 @@ import { import { EDITOR_AGENT_MAX_ATTACHMENTS, type EditorAgentAttachmentRef, - type EditorAgentGenerationResultEvent, - type EditorAgentStage, } from '@/packages/shared/src/contracts'; import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx'; import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx'; @@ -32,6 +29,7 @@ import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversa import { AttachmentChip, MessageBubble, + ThinkingBubble, } from '@/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx'; import type { CanvasLayer, @@ -61,7 +59,7 @@ type EditorAgentConversationPanelViewProps = { onToggleOpen: () => void; layers?: CanvasLayer[]; assets?: EditorAsset[]; - onGenerationResult?: (event: EditorAgentGenerationResultEvent) => void; + onCanvasRefreshRequested?: () => void; client?: EditorAgentConversationClient; }; @@ -125,183 +123,6 @@ function createLibraryAttachmentOptions( }); } -function stageLabel(stage: EditorAgentStage) { - if (stage === 'thinking') { - return '思考中'; - } - if (stage === 'responding') { - return '回复中'; - } - if (stage === 'generating') { - return '生成中'; - } - if (stage === 'completed') { - return '完成'; - } - if (stage === 'failed') { - return '失败'; - } - return ''; -} - -function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) { - if (toolName === 'edit_image') { - return '修改图片'; - } - if (toolName === 'generate_character') { - return '生成角色'; - } - if (toolName === 'generate_icon_spritesheet') { - return '生成图标'; - } - if (toolName === 'generate_ui_design') { - return '生成 UI'; - } - return '生成图片'; -} - -function messageRoleLabel(role: EditorAgentMessage['role']) { - return role === 'user' ? '你' : 'Agent'; -} - -function AttachmentChip({ - attachment, - onRemove, -}: { - attachment: EditorAgentAttachmentRef; - onRemove?: () => void; -}) { - const label = attachment.label?.trim() || attachment.referenceId; - return ( - - - ); -} - -function GenerationRecordsView({ - generations, -}: { - generations: EditorAgentGenerationRecord[]; -}) { - if (!generations.length) { - return null; - } - return ( -
- {generations.map((generation) => ( -
- {generation.status === 'generating' || generation.error ? ( -
- {generation.status === 'generating' ? ( -
- ) : null} - {generation.error ? ( -
{generation.error}
- ) : null} - {generation.images.length ? ( -
- {generation.images.map((image, index) => ( -
- -
- ))} -
- ) : null} -
- ))} -
- ); -} - -function MessageBubble({ - message, -}: { - message: EditorAgentMessage; -}) { - const isUser = message.role === 'user'; - return ( -
-
-
- {message.text || (message.status === 'streaming' ? '...' : '')} -
- {message.attachments.length ? ( -
- {message.attachments.map((attachment) => ( - - ))} -
- ) : null} - -
-
- ); -} - function AttachmentPickerModal({ open, tab, @@ -415,7 +236,7 @@ export function EditorAgentConversationPanelView({ onToggleOpen, layers = [], assets = [], - onGenerationResult, + onCanvasRefreshRequested, client, }: EditorAgentConversationPanelViewProps) { const [hasConversationMounted, setHasConversationMounted] = useState(open); @@ -424,30 +245,31 @@ export function EditorAgentConversationPanelView({ setHasConversationMounted(true); } }, [open]); - const projectId = useImageCanvasContextStore( - (state) => state.projectId, - ); + const projectId = useImageCanvasContextStore((state) => state.projectId); const effectiveProjectId = hasConversationMounted ? projectId : null; const { conversations, activeConversationId, messages, - stage, isLoadingConversations, isLoadingMessages, isCreatingConversation, isDeletingConversation, - isStreaming, + isWaiting, + toolCallAction, + isToolCallActionPending, errorMessage, createConversation, selectConversation, sendMessage, stopCurrentTurn, + confirmToolCall, + cancelToolCall, deleteActiveConversation, } = useEditorAgentConversation({ projectId: effectiveProjectId, client, - onGenerationResult, + onCanvasRefreshRequested, }); const [draftText, setDraftText] = useState(''); const [attachments, setAttachments] = useState( @@ -479,8 +301,8 @@ export function EditorAgentConversationPanelView({ return optionMap; }, [canvasAttachmentOptions, libraryAttachmentOptions]); - const currentStageLabel = stageLabel(stage); const hasProject = Boolean(projectId?.trim()); + const isConversationBusy = isWaiting || isToolCallActionPending; const openAttachmentPicker = () => { setAttachmentError(null); @@ -516,7 +338,7 @@ export function EditorAgentConversationPanelView({ const submitMessage = (event: FormEvent) => { event.preventDefault(); - if (isStreaming) { + if (isWaiting) { stopCurrentTurn(); return; } @@ -612,7 +434,9 @@ export function EditorAgentConversationPanelView({ return [...fileItems]; } - const handleInputPaste = (event: ReactClipboardEvent) => { + const handleInputPaste = ( + event: ReactClipboardEvent, + ) => { const imageFiles = extractClipboardImageFiles(event.clipboardData); if (!imageFiles.length) { return; @@ -684,7 +508,9 @@ export function EditorAgentConversationPanelView({ aria-label="当前对话" value={activeConversationId ?? ''} disabled={ - !conversations.length || isLoadingConversations || isStreaming + !conversations.length || + isLoadingConversations || + isConversationBusy } onChange={(event) => { const nextConversationId = event.currentTarget.value; @@ -699,7 +525,7 @@ export function EditorAgentConversationPanelView({ key={conversation.conversationId} value={conversation.conversationId} > - {conversation.title} + {conversation.conversationId} )) ) : ( @@ -710,7 +536,9 @@ export function EditorAgentConversationPanelView({ type="button" className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-45" aria-label="新建对话" - disabled={!hasProject || isCreatingConversation || isStreaming} + disabled={ + !hasProject || isCreatingConversation || isConversationBusy + } onClick={() => void createConversation()} >