impl confirm cancel flow
This commit is contained in:
+190
-80
@@ -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 () => {
|
||||
|
||||
+58
-212
@@ -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 '生成图片';
|
||||
}
|
||||
+193
-198
File diff suppressed because it is too large
Load Diff
+146
-298
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user