impl confirm cancel flow

This commit is contained in:
2026-07-10 11:33:13 +08:00
parent 74361af6bd
commit e5eee1dd4e
7 changed files with 870 additions and 935 deletions
@@ -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<typeof import('../ImageCanvasFileModel.ts')>(
'./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<void>((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(
<EditorAgentConversationPanelView
@@ -433,8 +431,6 @@ describe('EditorAgentConversationPanelView', () => {
await waitFor(() => {
expect(screen.getByText('我来生成图片。')).toBeTruthy();
});
expect(screen.getByText('生成中')).toBeTruthy();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
@@ -453,13 +449,127 @@ describe('EditorAgentConversationPanelView', () => {
/>,
);
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<EditorAgentMessage>((resolve) => {
resolveConfirmation = resolve;
}),
);
const onCanvasRefreshRequested = vi.fn();
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
onCanvasRefreshRequested={onCanvasRefreshRequested}
/>,
);
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(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
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 () => {
@@ -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 (
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
{onRemove ? (
<button
type="button"
className="-mr-1 inline-flex h-5 w-5 items-center justify-center rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label={`移除附件 ${label}`}
onClick={onRemove}
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
) : null}
{attachment.thumbnailSrc || attachment.imageSrc ? (
<span className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 hidden rounded-2xl border border-white bg-white p-1 shadow-xl group-hover:block">
<ResolvedAssetImage
src={attachment.thumbnailSrc ?? attachment.imageSrc}
objectKey={attachment.objectKey}
refreshKey={attachment.referenceId}
alt=""
className="h-24 w-24 rounded-xl object-cover"
/>
</span>
) : null}
</span>
);
}
function GenerationRecordsView({
generations,
}: {
generations: EditorAgentGenerationRecord[];
}) {
if (!generations.length) {
return null;
}
return (
<div className="mt-2 space-y-2">
{generations.map((generation) => (
<div
key={generation.toolCallId}
className="rounded-2xl border border-slate-200 bg-white/80 p-2 text-xs text-slate-600"
>
{generation.status === 'generating' || generation.error ? (
<div className="flex items-center gap-2">
{generation.status === 'generating' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>{toolLabel(generation.toolName)}</span>
{generation.model ? <span>{generation.model}</span> : null}
</div>
) : null}
{generation.error ? (
<div className="mt-1 text-red-600">{generation.error}</div>
) : null}
{generation.images.length ? (
<div className="mt-2 grid grid-cols-3 gap-2">
{generation.images.map((image, index) => (
<div
key={`${generation.toolCallId}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={
image.resourceId ??
generation.taskId ??
generation.toolCallId
}
alt=""
className="h-20 w-full object-cover"
/>
</div>
))}
</div>
) : null}
</div>
))}
</div>
);
}
function MessageBubble({
message,
}: {
message: EditorAgentMessage;
}) {
const isUser = message.role === 'user';
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={`${messageRoleLabel(message.role)}消息`}
>
<div
className={`max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: message.kind === 'error'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`}
>
<div className="whitespace-pre-wrap break-words">
{message.text || (message.status === 'streaming' ? '...' : '')}
</div>
{message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
key={attachmentKey(attachment)}
attachment={attachment}
/>
))}
</div>
) : null}
<GenerationRecordsView
generations={message.generations}
/>
</div>
</article>
);
}
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<EditorAgentAttachmentRef[]>(
@@ -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<HTMLFormElement>) => {
event.preventDefault();
if (isStreaming) {
if (isWaiting) {
stopCurrentTurn();
return;
}
@@ -612,7 +434,9 @@ export function EditorAgentConversationPanelView({
return [...fileItems];
}
const handleInputPaste = (event: ReactClipboardEvent<HTMLTextAreaElement>) => {
const handleInputPaste = (
event: ReactClipboardEvent<HTMLTextAreaElement>,
) => {
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}
</option>
))
) : (
@@ -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()}
>
<Plus className="h-4 w-4" aria-hidden="true" />
@@ -720,7 +548,9 @@ export function EditorAgentConversationPanelView({
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-45"
aria-label="删除当前对话"
disabled={
!activeConversationId || isDeletingConversation || isStreaming
!activeConversationId ||
isDeletingConversation ||
isConversationBusy
}
onClick={() => setDeleteConfirmOpen(true)}
>
@@ -735,15 +565,16 @@ export function EditorAgentConversationPanelView({
<X className="h-4 w-4" aria-hidden="true" />
</button>
</header>
{currentStageLabel ? (
{isConversationBusy ? (
<div className="flex items-center gap-2 border-b border-slate-200 bg-white/60 px-4 py-2 text-xs text-slate-500">
{isStreaming ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : null}
<span>{currentStageLabel}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
<span>
{toolCallAction?.action === 'confirm'
? '执行中'
: toolCallAction?.action === 'cancel'
? '取消中'
: '思考中'}
</span>
</div>
) : null}
<div
@@ -757,12 +588,26 @@ export function EditorAgentConversationPanelView({
</div>
) : messages.length ? (
messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
/>
))
<>
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
busyAction={
toolCallAction?.messageId === message.id
? toolCallAction.action
: null
}
onConfirmToolCall={(messageId) => {
void confirmToolCall(messageId);
}}
onCancelToolCall={(messageId) => {
void cancelToolCall(messageId);
}}
/>
))}
{isWaiting ? <ThinkingBubble /> : null}
</>
) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
@@ -821,11 +666,12 @@ export function EditorAgentConversationPanelView({
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
!isStreaming &&
((!draftText.trim() && !attachments.length) || !hasProject)
isToolCallActionPending ||
(!isWaiting &&
((!draftText.trim() && !attachments.length) || !hasProject))
}
>
{isStreaming ? (
{isWaiting ? (
<>
<Square className="h-3.5 w-3.5" aria-hidden="true" />
@@ -1,42 +1,20 @@
import { FileAudio, Image as ImageIcon, Loader2, X } from 'lucide-react';
import { Check, Image as ImageIcon, Loader2, X } from 'lucide-react';
import type {
EditorAgentAttachmentRef,
EditorAgentGenerationRecord,
EditorAgentMessage,
EditorAgentToolCall,
} from '@/packages/shared/src/contracts';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts';
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
import { useResolvedAssetReadUrl } from '@/src/hooks/useResolvedAssetReadUrl.ts';
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';
}
if (toolName === 'generate_video') {
return '生成视频';
}
if (toolName === 'generate_sound_effect') {
return '生成音效';
}
if (toolName === 'generate_background_music') {
return '生成背景音乐';
}
return '生成图片';
}
function messageRoleLabel(role: EditorAgentMessage['role']) {
return role === 'user' ? '你' : 'Agent';
if (role === 'user') {
return '你';
}
return 'Agent';
}
export function AttachmentChip({
@@ -76,139 +54,140 @@ export function AttachmentChip({
);
}
function GenerationRecordsView({
generations,
}: {
generations: EditorAgentGenerationRecord[];
}) {
if (!generations.length) {
return null;
}
function ToolCallView({ toolCall }: { toolCall: EditorAgentToolCall }) {
const isExecuting = toolCall.status === 'executing';
const statusLabel =
toolCall.status === 'completed'
? '已完成'
: toolCall.status === 'cancelled'
? '已取消'
: toolCall.status === 'failed'
? '失败'
: toolCall.status === 'pending_confirmation'
? '待确认'
: '执行中';
return (
<div className="mt-2 space-y-2">
{generations.map((generation) => (
<div
key={generation.toolCallId}
className="rounded-2xl border border-slate-200 bg-white/80 p-2 text-xs text-slate-600"
>
{generation.status === 'generating' || generation.error ? (
<div className="flex items-center gap-2">
{generation.status === 'generating' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>{toolLabel(generation.toolName)}</span>
{generation.model ? <span>{generation.model}</span> : null}
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
{isExecuting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : toolCall.status === 'completed' ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : toolCall.status === 'cancelled' ? (
<X className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>{editorAgentToolLabel(toolCall.toolName)}</span>
<span className="ml-auto text-slate-400">{statusLabel}</span>
</div>
{toolCall.error ? (
<div className="mt-1 text-red-600">{toolCall.error}</div>
) : null}
{toolCall.images.length ? (
<div className="mt-2 grid grid-cols-3 gap-2">
{toolCall.images.map((image, index) => (
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={image.resourceId ?? toolCall.toolName}
alt=""
className="h-20 w-full object-cover"
/>
</div>
) : null}
{generation.error ? (
<div className="mt-1 text-red-600">{generation.error}</div>
) : null}
{generation.images.length ? (
<div className="mt-2 grid grid-cols-3 gap-2">
{generation.images.map((image, index) => (
<div
key={`${generation.toolCallId}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetImage
src={image.thumbnailSrc ?? image.imageSrc}
objectKey={image.objectKey}
refreshKey={
image.resourceId ??
generation.taskId ??
generation.toolCallId
}
alt=""
className="h-20 w-full object-cover"
/>
</div>
))}
</div>
) : null}
{generation.videos.length ? (
<div className="mt-2 grid grid-cols-1 gap-2">
{generation.videos.map((video, index) => (
<div
key={`${generation.toolCallId}-${video.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetVideo
src={video.videoSrc}
objectKey={video.objectKey}
poster={video.thumbnailSrc ?? undefined}
preload="metadata"
// the height is decided by width (already stable) and the video aspect radio
className="h-full w-full object-cover"
controls
playsInline
/>
</div>
))}
</div>
) : null}
{generation.audios.length ? (
<div className="mt-2 grid grid-cols-1 gap-2">
{generation.audios.map((audio, index) => (
<div
key={`${generation.toolCallId}-${audio.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
>
<ResolvedAssetAudio
audio={audio}
/>
</div>
))}
</div>
) : null}
))}
</div>
))}
) : null}
</div>
);
}
function ResolvedAssetAudio({
audio,
}: {
audio: EditorAgentGenerationRecord['audios'][number];
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(audio.audioSrc, {
objectKey: audio.objectKey,
});
export function ThinkingBubble() {
return (
<audio
controls
preload="metadata"
src={resolvedUrl == '' ? null : resolvedUrl}
className="w-full"
aria-label="生成音频预览"
/>
<article className="flex justify-start" aria-label="Agent思考中">
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
<div className="flex items-center gap-1.5">
<span className="flex gap-0.5">
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '0ms' }}
/>
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '150ms' }}
/>
<span
className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"
style={{ animationDelay: '300ms' }}
/>
</span>
</div>
</div>
</article>
);
}
export function MessageBubble({ message }: { message: EditorAgentMessage }) {
type MessageBubbleProps = {
message: EditorAgentMessage;
busyAction: 'confirm' | 'cancel' | null;
onConfirmToolCall: (messageId: number) => void;
onCancelToolCall: (messageId: number) => void;
};
export function MessageBubble({
message,
busyAction,
onConfirmToolCall,
onCancelToolCall,
}: MessageBubbleProps) {
if (message.role === 'system' && !message.toolCall) {
return null;
}
if (
message.role === 'system' &&
message.toolCall?.status === 'pending_confirmation'
) {
return (
<PendingToolCall
messageId={message.id}
toolCall={message.toolCall}
busyAction={busyAction}
onConfirm={onConfirmToolCall}
onCancel={onCancelToolCall}
/>
);
}
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<article
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
aria-label={`${messageRoleLabel(message.role)}消息`}
aria-label={
isSystem ? 'Agent操作' : `${messageRoleLabel(message.role)}消息`
}
>
<div
className={`max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: message.kind === 'error'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`}
className={
isSystem
? 'max-w-[86%]'
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
isUser
? 'bg-slate-900 text-white'
: message.toolCall?.status === 'failed'
? 'border border-red-200 bg-red-50 text-red-700'
: 'border border-slate-200 bg-white text-slate-700'
}`
}
>
<div className="whitespace-pre-wrap break-words">
{message.text || (message.status === 'streaming' ? '...' : '')}
</div>
{message.attachments.length ? (
{!isSystem && message.text ? (
<div className="whitespace-pre-wrap break-words">{message.text}</div>
) : null}
{!isSystem && message.attachments.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{message.attachments.map((attachment) => (
<AttachmentChip
@@ -218,7 +197,7 @@ export function MessageBubble({ message }: { message: EditorAgentMessage }) {
))}
</div>
) : null}
<GenerationRecordsView generations={message.generations} />
{message.toolCall ? <ToolCallView toolCall={message.toolCall} /> : null}
</div>
</article>
);
@@ -0,0 +1,133 @@
import { Check, Loader2, Pencil, X } from 'lucide-react';
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts';
type PendingToolCallAction = 'confirm' | 'cancel' | null;
type PendingToolCallProps = {
messageId: number;
toolCall: EditorAgentToolCall;
busyAction: PendingToolCallAction;
onConfirm: (messageId: number) => void;
onCancel: (messageId: number) => void;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value.map(readString).filter((item): item is string => item !== null)
: [];
}
function readPendingToolDetails(toolCall: EditorAgentToolCall) {
const args = isRecord(toolCall.args) ? toolCall.args : {};
const prompt = readString(args.prompt);
const targetImage =
readString(args.object_image_id) ?? readString(args.objectImageId);
const referenceImages = readStringArray(
args.reference_image_ids ?? args.referenceImageIds,
);
const summary = readString(toolCall.summary);
return {
prompt: prompt ?? (summary?.startsWith('{') ? null : summary),
targetImage,
referenceImageCount: referenceImages.length,
};
}
export function PendingToolCall({
messageId,
toolCall,
busyAction,
onConfirm,
onCancel,
}: PendingToolCallProps) {
const details = readPendingToolDetails(toolCall);
const label = editorAgentToolLabel(toolCall.toolName);
const isBusy = busyAction !== null;
return (
<article className="flex justify-start" aria-label={`待确认的${label}操作`}>
<div className="w-full max-w-[86%] rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-700 shadow-sm">
<div className="flex items-center gap-2 font-medium text-slate-900">
<Pencil className="h-4 w-4 shrink-0" aria-hidden="true" />
<span>{label}</span>
<span className="ml-auto text-xs font-normal text-amber-700">
</span>
</div>
{details.prompt ? (
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-5">
{details.prompt}
</p>
) : null}
{details.targetImage || details.referenceImageCount > 0 ? (
<dl className="mt-2 space-y-1 text-xs text-slate-500">
{details.targetImage ? (
<div className="flex min-w-0 gap-2">
<dt className="shrink-0"></dt>
<dd className="min-w-0 break-all text-slate-700">
{details.targetImage}
</dd>
</div>
) : null}
{details.referenceImageCount > 0 ? (
<div className="flex gap-2">
<dt></dt>
<dd className="text-slate-700">
{details.referenceImageCount}
</dd>
</div>
) : null}
</dl>
) : null}
<div className="mt-3 flex justify-end gap-2">
<button
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={() => onCancel(messageId)}
>
{busyAction === 'cancel' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<X className="h-3.5 w-3.5" aria-hidden="true" />
)}
{busyAction === 'cancel' ? '取消中' : '取消'}
</button>
<button
type="button"
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-slate-900 px-3 text-sm font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isBusy}
onClick={() => onConfirm(messageId)}
>
{busyAction === 'confirm' ? (
<Loader2
className="h-3.5 w-3.5 animate-spin"
aria-hidden="true"
/>
) : (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
)}
{busyAction === 'confirm' ? '执行中' : '确认'}
</button>
</div>
</div>
</article>
);
}
@@ -0,0 +1,24 @@
export function editorAgentToolLabel(toolName: string) {
if (toolName === 'edit-image' || toolName === 'edit_image') {
return '修改图片';
}
if (toolName === 'generate_character') {
return '生成角色';
}
if (toolName === 'generate_icon_spritesheet') {
return '生成图标';
}
if (toolName === 'generate_ui_design') {
return '生成 UI';
}
if (toolName === 'generate_video') {
return '生成视频';
}
if (toolName === 'generate_sound_effect') {
return '生成音效';
}
if (toolName === 'generate_background_music') {
return '生成背景音乐';
}
return '生成图片';
}
File diff suppressed because it is too large Load Diff