Merge remote-tracking branch 'origin/master' into fix/multi-select
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
+310
-30
@@ -11,6 +11,7 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessageResponse,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
@@ -23,6 +24,7 @@ import { EditorAgentConversationPanelView } from './EditorAgentConversationPanel
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const probeImageFileDimensionsMock = vi.hoisted(() => vi.fn());
|
||||
const ATTACHMENT_PROMPT = '请参考附件';
|
||||
|
||||
vi.mock('@/src/services/image-editor/editorProjectClient.ts', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -115,6 +117,12 @@ function createClient(): EditorAgentConversationClient {
|
||||
};
|
||||
}
|
||||
|
||||
function enterAttachmentPrompt() {
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: ATTACHMENT_PROMPT },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -218,7 +226,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
{
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '角色图层',
|
||||
title: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳',
|
||||
src: '/generated/role.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -264,11 +272,13 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
const newConversationButton = screen.getByRole('button', {
|
||||
name: '新建对话',
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(false);
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '应保留到新会话的草稿' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
@@ -284,13 +294,43 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: '选择画布图片 角色图层',
|
||||
name: '选择画布图片 一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(newConversationButton);
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
|
||||
).toBe('conversation-2');
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('应保留到新会话的草稿');
|
||||
expect(
|
||||
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('option', { name: '角色参考' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '尚未发送的草稿' },
|
||||
});
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '参考附件做像素风' },
|
||||
@@ -308,6 +348,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -391,13 +432,14 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
||||
|
||||
expect(await screen.findByText('Agent生成图片-1')).toBeTruthy();
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
text: ATTACHMENT_PROMPT,
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
@@ -412,6 +454,242 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps new conversation disabled when the project has no conversation history', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.listConversations).toHaveBeenCalledWith('project-1');
|
||||
expect(screen.getByText('暂无消息')).toBeTruthy();
|
||||
});
|
||||
const newConversationButton = screen.getByRole('button', {
|
||||
name: '新建对话',
|
||||
}) as HTMLButtonElement;
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '未发送内容' },
|
||||
});
|
||||
expect(newConversationButton.disabled).toBe(true);
|
||||
fireEvent.click(newConversationButton);
|
||||
expect(client.createConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the current conversation draft when creating a conversation fails', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.createConversation).mockRejectedValueOnce(
|
||||
new Error('创建新会话失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '需要保留的草稿' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('创建新会话失败')).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
|
||||
).toBe('需要保留的草稿');
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
|
||||
).toBe('conversation-1');
|
||||
});
|
||||
|
||||
it('preserves newer draft edits while creating a conversation', async () => {
|
||||
const client = createClient();
|
||||
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
|
||||
vi.mocked(client.createConversation).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentConversationDetail>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '旧项目草稿' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
expect(draftInput.disabled).toBe(false);
|
||||
});
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
) as HTMLSelectElement;
|
||||
const sendButton = screen.getByRole('button', {
|
||||
name: '发送',
|
||||
}) as HTMLButtonElement;
|
||||
expect(conversationSelect.disabled).toBe(true);
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '创建期间更新的草稿' },
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:01:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(draftInput.value).toBe('创建期间更新的草稿');
|
||||
expect(conversationSelect.value).toBe('conversation-2');
|
||||
expect(conversationSelect.disabled).toBe(false);
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the draft when submission is attempted during conversation loading', async () => {
|
||||
const client = createClient();
|
||||
let resolveConversationLoad!: (
|
||||
detail: EditorAgentConversationDetail,
|
||||
) => void;
|
||||
vi.mocked(client.listConversations).mockResolvedValueOnce([
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
},
|
||||
{
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '背景参考',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(client.getConversation)
|
||||
.mockResolvedValueOnce({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [
|
||||
{
|
||||
id: 0,
|
||||
role: 'assistant',
|
||||
text: '已经看到画布内容',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:10.000Z',
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentConversationDetail>((resolve) => {
|
||||
resolveConversationLoad = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
const draftInput = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
const conversationSelect = screen.getByLabelText(
|
||||
'当前对话',
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(draftInput, {
|
||||
target: { value: '切换期间必须保留的草稿' },
|
||||
});
|
||||
fireEvent.change(conversationSelect, {
|
||||
target: { value: 'conversation-2' },
|
||||
});
|
||||
|
||||
const sendButton = screen.getByRole('button', {
|
||||
name: '发送',
|
||||
}) as HTMLButtonElement;
|
||||
await waitFor(() => {
|
||||
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
});
|
||||
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
|
||||
await act(async () => {
|
||||
resolveConversationLoad({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '背景参考',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'assistant',
|
||||
text: '背景会话内容',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('背景会话内容')).toBeTruthy();
|
||||
expect(draftInput.value).toBe('切换期间必须保留的草稿');
|
||||
expect(sendButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables sending while a message request is pending without showing stop', async () => {
|
||||
const client = createClient();
|
||||
let resolveSend!: (response: EditorAgentMessageResponse) => void;
|
||||
@@ -522,13 +800,14 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.getByText('粘贴图片')).toBeTruthy();
|
||||
});
|
||||
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
text: ATTACHMENT_PROMPT,
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
@@ -546,7 +825,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
|
||||
});
|
||||
|
||||
it('references a historical pasted attachment outside the current canvas', async () => {
|
||||
it('normalizes a referenced historical attachment outside the current canvas', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.getConversation).mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
@@ -564,7 +843,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
objectKey: 'editor/agent-paste/historical.png',
|
||||
imageSrc: '/generated/historical-paste.png',
|
||||
thumbnailSrc: null,
|
||||
label: '历史粘贴图',
|
||||
label: ' 历史(粘贴):图 ',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
@@ -587,13 +866,14 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const historicalAttachment = await screen.findByText('历史粘贴图');
|
||||
const historicalAttachment = await screen.findByText('历史(粘贴):图');
|
||||
fireEvent.contextMenu(historicalAttachment.closest('.group')!);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('历史粘贴图')).toHaveLength(2);
|
||||
expect(screen.getByText('历史粘贴图')).toBeTruthy();
|
||||
});
|
||||
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
@@ -615,6 +895,8 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
expect(screen.getByText('历史(粘贴):图')).toBeTruthy();
|
||||
expect(screen.getByText('历史粘贴图')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('refreshes an attachment matched by source and reference id when sending', async () => {
|
||||
@@ -678,6 +960,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
expect(screen.queryByText('最新附件')).toBeNull();
|
||||
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
@@ -808,6 +1091,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
expect(await screen.findByText('粘贴图片')).toBeTruthy();
|
||||
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
@@ -950,6 +1234,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(await screen.findByText('最多 9 张')).toBeTruthy();
|
||||
expect(screen.queryByText('粘贴图片')).toBeNull();
|
||||
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
|
||||
@@ -967,7 +1252,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends selected attachments even when the text input is empty', async () => {
|
||||
it('rejects selected attachments when the text input is empty', async () => {
|
||||
const client = createClient();
|
||||
|
||||
render(
|
||||
@@ -1010,23 +1295,15 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
const sendButton = screen.getByRole('button', {
|
||||
name: '发送',
|
||||
}) as HTMLButtonElement;
|
||||
expect(sendButton.disabled).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
fireEvent.submit(sendButton.closest('form')!);
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('restores the draft and selected attachments when sending fails', async () => {
|
||||
@@ -1192,6 +1469,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1276,6 +1554,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1387,6 +1666,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
enterAttachmentPrompt();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
+84
-54
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Paperclip,
|
||||
@@ -19,6 +20,7 @@ import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDan
|
||||
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
||||
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import { EditorAgentDraftTextarea } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx';
|
||||
import {
|
||||
MessageBubble,
|
||||
ThinkingBubble,
|
||||
@@ -69,6 +71,7 @@ export function EditorAgentConversationPanelView({
|
||||
const effectiveProjectId = hasConversationMounted ? projectId : null;
|
||||
const {
|
||||
conversations,
|
||||
activeConversation,
|
||||
activeConversationId,
|
||||
messages,
|
||||
isLoadingConversations,
|
||||
@@ -118,19 +121,33 @@ export function EditorAgentConversationPanelView({
|
||||
|
||||
const hasProject = Boolean(projectId?.trim());
|
||||
const isConversationBusy = isWaiting || isToolCallActionPending;
|
||||
const hasCurrentConversationContent = messages.length > 0;
|
||||
const isConversationSelectDisabled =
|
||||
!conversations.length ||
|
||||
isLoadingConversations ||
|
||||
isCreatingConversation ||
|
||||
isConversationBusy;
|
||||
const isMessageSubmissionBlocked =
|
||||
isCreatingConversation ||
|
||||
isLoadingConversations ||
|
||||
isLoadingMessages ||
|
||||
isWaiting ||
|
||||
isToolCallActionPending ||
|
||||
isPastingAttachment ||
|
||||
!draftText.trim() ||
|
||||
!hasProject;
|
||||
const currentConversationTitle = activeConversation?.title ?? '新对话';
|
||||
|
||||
const handleCreateConversation = () => {
|
||||
void createConversation().catch(() => undefined);
|
||||
};
|
||||
|
||||
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isWaiting) {
|
||||
return;
|
||||
}
|
||||
if (isPastingAttachment) {
|
||||
if (isMessageSubmissionBlocked) {
|
||||
return;
|
||||
}
|
||||
const text = draftText.trim();
|
||||
if (!text && !attachments.length) {
|
||||
return;
|
||||
}
|
||||
setDraftText('');
|
||||
const nextAttachments = consumeAttachments();
|
||||
void sendMessage(text, nextAttachments).catch(() => {
|
||||
@@ -164,43 +181,70 @@ export function EditorAgentConversationPanelView({
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-slate-200 bg-white/90 px-3 py-3">
|
||||
<Bot className="h-5 w-5 text-slate-700" aria-hidden="true" />
|
||||
<select
|
||||
className="min-w-0 flex-1 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
aria-label="当前对话"
|
||||
value={activeConversationId ?? ''}
|
||||
disabled={
|
||||
!conversations.length ||
|
||||
isLoadingConversations ||
|
||||
isConversationBusy
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextConversationId = event.currentTarget.value;
|
||||
if (nextConversationId) {
|
||||
void selectConversation(nextConversationId);
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className="relative min-w-0 flex-1 rounded-full focus-within:ring-2 focus-within:ring-slate-300"
|
||||
title={currentConversationTitle}
|
||||
>
|
||||
{conversations.length ? (
|
||||
conversations.map((conversation) => (
|
||||
<option
|
||||
key={conversation.conversationId}
|
||||
value={conversation.conversationId}
|
||||
>
|
||||
{conversation.title}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">新对话</option>
|
||||
)}
|
||||
</select>
|
||||
<div
|
||||
className={`pointer-events-none flex h-9 w-full items-center rounded-full border border-slate-200 bg-white px-3 pr-10 text-sm ${
|
||||
isConversationSelectDisabled
|
||||
? 'text-slate-400'
|
||||
: 'text-slate-800'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid="active-conversation-title"
|
||||
>
|
||||
{currentConversationTitle}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="absolute right-[10px] top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
data-testid="conversation-switch-chevron"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed"
|
||||
aria-label="当前对话"
|
||||
value={activeConversationId ?? ''}
|
||||
disabled={isConversationSelectDisabled}
|
||||
onChange={(event) => {
|
||||
const nextConversationId = event.currentTarget.value;
|
||||
if (nextConversationId) {
|
||||
void selectConversation(nextConversationId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{conversations.length ? (
|
||||
conversations.map((conversation) => (
|
||||
<option
|
||||
key={conversation.conversationId}
|
||||
value={conversation.conversationId}
|
||||
>
|
||||
{conversation.title}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">新对话</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
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 || isConversationBusy
|
||||
!hasProject ||
|
||||
!hasCurrentConversationContent ||
|
||||
isLoadingConversations ||
|
||||
isLoadingMessages ||
|
||||
isCreatingConversation ||
|
||||
isConversationBusy ||
|
||||
isPastingAttachment
|
||||
}
|
||||
onClick={() => void createConversation()}
|
||||
onClick={handleCreateConversation}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -312,29 +356,15 @@ export function EditorAgentConversationPanelView({
|
||||
>
|
||||
<Paperclip className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<textarea
|
||||
className="max-h-32 min-h-10 flex-1 resize-none overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
<EditorAgentDraftTextarea
|
||||
value={draftText}
|
||||
rows={1}
|
||||
onChange={(event) => setDraftText(event.currentTarget.value)}
|
||||
onChange={setDraftText}
|
||||
onPaste={handleInputPaste}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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={
|
||||
isWaiting ||
|
||||
isToolCallActionPending ||
|
||||
(!draftText.trim() && !attachments.length) ||
|
||||
!hasProject
|
||||
}
|
||||
disabled={isMessageSubmissionBlocked}
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
发送
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('EditorAgentDraftTextarea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
|
||||
render(<EditorAgentDraftTextarea value="" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新草稿' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新草稿');
|
||||
});
|
||||
|
||||
it('falls back to measured sizing and remeasures when width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<EditorAgentDraftTextarea value="" onChange={vi.fn()} />,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<EditorAgentDraftTextarea
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(input.scrollHeight).toBeLessThanOrEqual(input.clientHeight);
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
|
||||
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('发送给画布 Agent');
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, {
|
||||
key: 'Enter',
|
||||
isComposing: false,
|
||||
keyCode: 229,
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
type ClipboardEventHandler,
|
||||
type KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
const DRAFT_MIN_HEIGHT_PX = 40;
|
||||
const DRAFT_MAX_HEIGHT_PX = 128;
|
||||
|
||||
// for fallback on some browser
|
||||
function supportsNativeFieldSizing() {
|
||||
return (
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('field-sizing', 'content')
|
||||
);
|
||||
}
|
||||
|
||||
function resizeFallbackTextarea(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.height = 'auto';
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const borderHeight = Math.max(
|
||||
0,
|
||||
textarea.offsetHeight - textarea.clientHeight,
|
||||
);
|
||||
const borderBoxContentHeight = contentHeight + borderHeight;
|
||||
const nextHeight = Math.min(
|
||||
Math.max(borderBoxContentHeight, DRAFT_MIN_HEIGHT_PX),
|
||||
DRAFT_MAX_HEIGHT_PX,
|
||||
);
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY =
|
||||
borderBoxContentHeight > DRAFT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
export function EditorAgentDraftTextarea({
|
||||
value,
|
||||
onChange,
|
||||
onPaste,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return;
|
||||
}
|
||||
resizeFallbackTextarea(textarea);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let previousWidth = textarea.getBoundingClientRect().width;
|
||||
const resizeWhenWidthChanges = (nextWidth: number) => {
|
||||
if (Math.abs(nextWidth - previousWidth) < 0.5) {
|
||||
return;
|
||||
}
|
||||
previousWidth = nextWidth;
|
||||
resizeFallbackTextarea(textarea);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
resizeWhenWidthChanges(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
event.nativeEvent.keyCode !== 229
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-agent-conversation__draft-input max-h-32 min-h-10 min-w-0 flex-1 resize-none overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none [field-sizing:content] focus:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Check, Coins, Loader2, Pencil, X } from 'lucide-react';
|
||||
|
||||
import type { EditorAgentToolCall } from '@/packages/shared/src/contracts';
|
||||
import { editorAgentToolLabel } from '@/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts';
|
||||
import { getEditorGenerationModelDisplayName } from '@/src/components/image-editor/ImageCanvasGenerationModel.ts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
|
||||
type PendingToolCallAction = 'confirm' | 'cancel' | null;
|
||||
@@ -18,6 +19,10 @@ function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function displayStringArgument(name: string, value: string) {
|
||||
return name === 'model' ? getEditorGenerationModelDisplayName(value) : value;
|
||||
}
|
||||
|
||||
export function PendingToolCall({
|
||||
messageId,
|
||||
toolCall,
|
||||
@@ -64,7 +69,7 @@ export function PendingToolCall({
|
||||
{argument.label}
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-sm leading-5 text-slate-800">
|
||||
{argument.value}
|
||||
{displayStringArgument(argument.name, argument.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
createEditorAgentAttachmentRef,
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
@@ -54,16 +55,17 @@ function createCanvasAttachmentOptions(
|
||||
layers: CanvasLayer[],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return layers.filter(isImageLayer).map((layer) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
const referenceId = layer.resourceId || layer.id;
|
||||
const attachment = createEditorAgentAttachmentRef({
|
||||
source: 'canvas_resource',
|
||||
referenceId: layer.resourceId || layer.id,
|
||||
referenceId,
|
||||
objectKey: layer.objectKey ?? null,
|
||||
imageSrc: layer.src,
|
||||
thumbnailSrc: layer.thumbnailSrc ?? null,
|
||||
label: layer.title,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '画布',
|
||||
@@ -76,7 +78,7 @@ function createLibraryAttachmentOptions(
|
||||
assets: EditorAsset[],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return assets.filter(isImageAsset).map((asset) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
const attachment = createEditorAgentAttachmentRef({
|
||||
source: 'library_asset',
|
||||
referenceId: asset.id,
|
||||
objectKey: asset.objectKey ?? null,
|
||||
@@ -85,7 +87,7 @@ function createLibraryAttachmentOptions(
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
});
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '素材库',
|
||||
@@ -143,16 +145,19 @@ async function createPastedAgentImageAttachment(
|
||||
height,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
return {
|
||||
source: 'canvas_resource',
|
||||
referenceId: resource.resourceId,
|
||||
objectKey: resource.objectKey ?? upload.objectKey,
|
||||
imageSrc: resource.imageSrc,
|
||||
thumbnailSrc: null,
|
||||
label: resource.label ?? '粘贴图片',
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
};
|
||||
return createEditorAgentAttachmentRef(
|
||||
{
|
||||
source: 'canvas_resource',
|
||||
referenceId: resource.resourceId,
|
||||
objectKey: resource.objectKey ?? upload.objectKey,
|
||||
imageSrc: resource.imageSrc,
|
||||
thumbnailSrc: null,
|
||||
// pasted file.name does not give more information
|
||||
label: resource.label ?? '粘贴图片',
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversationAttachments({
|
||||
@@ -260,7 +265,7 @@ export function useConversationAttachments({
|
||||
const referenceContextAsset = useCallback(
|
||||
(asset: EditorAgentContextAsset) => {
|
||||
if (asset.kind === 'attachment') {
|
||||
const directAttachment: EditorAgentAttachmentRef = {
|
||||
const directAttachment = createEditorAgentAttachmentRef({
|
||||
source: asset.source,
|
||||
referenceId: asset.referenceId,
|
||||
objectKey: asset.objectKey,
|
||||
@@ -269,7 +274,7 @@ export function useConversationAttachments({
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
});
|
||||
return directAttachment.referenceId.trim() &&
|
||||
directAttachment.imageSrc.trim()
|
||||
? appendAttachments([directAttachment])
|
||||
|
||||
+272
-21
@@ -3,10 +3,11 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessageResponse,
|
||||
import {
|
||||
createEditorAgentAttachmentRef,
|
||||
type EditorAgentConversationDetail,
|
||||
type EditorAgentMessage,
|
||||
type EditorAgentMessageResponse,
|
||||
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
|
||||
import {
|
||||
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
|
||||
@@ -264,6 +265,268 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let a refresh supersede an in-flight conversation activation', async () => {
|
||||
const client = createClient();
|
||||
let resolveActivation!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
const activationPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveActivation = resolve;
|
||||
},
|
||||
);
|
||||
const refreshPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
});
|
||||
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
|
||||
if (conversationId === 'conversation-2') {
|
||||
return activationPromise;
|
||||
}
|
||||
if (conversationId === 'conversation-1') {
|
||||
return refreshPromise;
|
||||
}
|
||||
throw new Error(`Unexpected conversation: ${conversationId}`);
|
||||
});
|
||||
|
||||
let pendingActivation!: Promise<EditorAgentConversationDetail | null>;
|
||||
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
|
||||
act(() => {
|
||||
pendingActivation = result.current.selectConversation('conversation-2');
|
||||
pendingRefresh = result.current.refreshActiveConversation();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '旧会话刷新结果',
|
||||
messages: [
|
||||
{
|
||||
id: 10,
|
||||
role: 'assistant',
|
||||
text: '旧会话刷新消息',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await pendingRefresh;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
expect(result.current.messages).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
resolveActivation({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '第二个会话',
|
||||
messages: [
|
||||
{
|
||||
id: 20,
|
||||
role: 'assistant',
|
||||
text: '第二个会话消息',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
updatedAt: '2026-07-03T00:03:00.000Z',
|
||||
});
|
||||
await pendingActivation;
|
||||
});
|
||||
|
||||
expect(result.current.activeConversationId).toBe('conversation-2');
|
||||
expect(result.current.messages[0]?.text).toBe('第二个会话消息');
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the latest activation loading while an older suppressed refresh finishes', async () => {
|
||||
const client = createClient();
|
||||
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
|
||||
const conversation2Promise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveConversation2 = resolve;
|
||||
},
|
||||
);
|
||||
const refreshPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
const conversation3Promise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveConversation3 = resolve;
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
});
|
||||
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
|
||||
if (conversationId === 'conversation-2') {
|
||||
return conversation2Promise;
|
||||
}
|
||||
if (conversationId === 'conversation-1') {
|
||||
return refreshPromise;
|
||||
}
|
||||
if (conversationId === 'conversation-3') {
|
||||
return conversation3Promise;
|
||||
}
|
||||
throw new Error(`Unexpected conversation: ${conversationId}`);
|
||||
});
|
||||
|
||||
let conversation2Load!: Promise<EditorAgentConversationDetail | null>;
|
||||
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
|
||||
let conversation3Load!: Promise<EditorAgentConversationDetail | null>;
|
||||
act(() => {
|
||||
conversation2Load = result.current.selectConversation('conversation-2');
|
||||
pendingRefresh = result.current.refreshActiveConversation();
|
||||
conversation3Load = result.current.selectConversation('conversation-3');
|
||||
});
|
||||
expect(result.current.isLoadingMessages).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '旧会话刷新结果',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await pendingRefresh;
|
||||
});
|
||||
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
expect(result.current.isLoadingMessages).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('切换期间不应发送');
|
||||
});
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveConversation3({
|
||||
conversationId: 'conversation-3',
|
||||
projectId: 'project-1',
|
||||
title: '第三个会话',
|
||||
messages: [
|
||||
{
|
||||
id: 30,
|
||||
role: 'assistant',
|
||||
text: '第三个会话消息',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:03:00.000Z',
|
||||
updatedAt: '2026-07-03T00:03:00.000Z',
|
||||
});
|
||||
await conversation3Load;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-3');
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
resolveConversation2({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '第二个会话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:02:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await conversation2Load;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-3');
|
||||
});
|
||||
|
||||
it('does not let a silent refresh replace a newly created conversation', async () => {
|
||||
const client = createClient();
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
|
||||
const refreshPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
const createPromise = new Promise<EditorAgentConversationDetail>(
|
||||
(resolve) => {
|
||||
resolveCreate = resolve;
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
});
|
||||
vi.mocked(client.getConversation).mockReturnValueOnce(refreshPromise);
|
||||
vi.mocked(client.createConversation).mockReturnValueOnce(createPromise);
|
||||
|
||||
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
|
||||
let pendingCreate!: Promise<EditorAgentConversationDetail>;
|
||||
act(() => {
|
||||
pendingRefresh = result.current.refreshActiveConversation();
|
||||
pendingCreate = result.current.createConversation();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:01:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
});
|
||||
await pendingCreate;
|
||||
});
|
||||
expect(result.current.activeConversationId).toBe('conversation-2');
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '旧对话刷新结果',
|
||||
messages: [
|
||||
{
|
||||
id: 10,
|
||||
role: 'assistant',
|
||||
text: '旧会话消息',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
});
|
||||
await pendingRefresh;
|
||||
});
|
||||
|
||||
expect(result.current.activeConversationId).toBe('conversation-2');
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('appends a lazily reconciled tool message delta', async () => {
|
||||
const client = createClient();
|
||||
const pendingMessage: EditorAgentMessage = {
|
||||
@@ -531,7 +794,7 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(result.current.isPatienceNoticeVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('allows sending an attachment-only message', async () => {
|
||||
it('rejects an attachment-only message before calling the client', async () => {
|
||||
const client = createClient();
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
@@ -545,29 +808,17 @@ describe('useEditorAgentConversation', () => {
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('', [
|
||||
{
|
||||
createEditorAgentAttachmentRef({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
objectKey: 'generated-editor-assets/resource-1.png',
|
||||
imageSrc: '/resource-1.png',
|
||||
label: '参考图',
|
||||
},
|
||||
label: '一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯辰巳',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies persisted backend planning errors as system messages', async () => {
|
||||
|
||||
@@ -158,7 +158,9 @@ export function useEditorAgentConversation({
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const normalizedProjectIdRef = useRef(normalizedProjectId);
|
||||
const activeConversationIdRef = useRef<string | null>(null);
|
||||
const pendingActivationConversationIdRef = useRef<string | null>(null);
|
||||
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
|
||||
const conversationLoadRequestSequenceRef = useRef(0);
|
||||
const conversationLoadRequestIdRef = useRef(0);
|
||||
const createConversationRequestIdRef = useRef(0);
|
||||
const isWaitingRef = useRef(false);
|
||||
@@ -224,28 +226,57 @@ export function useEditorAgentConversation({
|
||||
);
|
||||
|
||||
const loadConversation = useCallback(
|
||||
async (conversationId: string, options: { showLoading?: boolean } = {}) => {
|
||||
const requestId = conversationLoadRequestIdRef.current + 1;
|
||||
conversationLoadRequestIdRef.current = requestId;
|
||||
async (
|
||||
conversationId: string,
|
||||
options: {
|
||||
// `activate` may make conversationId active; `refresh` may apply only
|
||||
// while that same conversationId is still active.
|
||||
mode: 'activate' | 'refresh';
|
||||
showLoading?: boolean;
|
||||
},
|
||||
) => {
|
||||
const requestId = conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = requestId;
|
||||
const maySupersedeCurrentLoad =
|
||||
options.mode === 'activate' ||
|
||||
pendingActivationConversationIdRef.current === null;
|
||||
if (maySupersedeCurrentLoad) {
|
||||
conversationLoadRequestIdRef.current = requestId;
|
||||
}
|
||||
if (options.mode === 'activate') {
|
||||
pendingActivationConversationIdRef.current = conversationId;
|
||||
}
|
||||
const showLoading = options.showLoading ?? true;
|
||||
if (showLoading) {
|
||||
const canApplyResult = () =>
|
||||
conversationLoadRequestIdRef.current === requestId &&
|
||||
(options.mode === 'activate' ||
|
||||
(pendingActivationConversationIdRef.current === null &&
|
||||
activeConversationIdRef.current === conversationId));
|
||||
if (showLoading && maySupersedeCurrentLoad) {
|
||||
setIsLoadingMessages(true);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const detail = await client.getConversation(conversationId);
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
if (canApplyResult()) {
|
||||
applyConversationDetail(detail);
|
||||
}
|
||||
return detail;
|
||||
} catch (error) {
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
if (canApplyResult()) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (
|
||||
options.mode === 'activate' &&
|
||||
conversationLoadRequestIdRef.current === requestId &&
|
||||
pendingActivationConversationIdRef.current === conversationId
|
||||
) {
|
||||
pendingActivationConversationIdRef.current = null;
|
||||
}
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
setIsLoadingMessages(false);
|
||||
}
|
||||
@@ -255,7 +286,11 @@ export function useEditorAgentConversation({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
conversationLoadRequestIdRef.current += 1;
|
||||
const invalidationRequestId =
|
||||
conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = invalidationRequestId;
|
||||
conversationLoadRequestIdRef.current = invalidationRequestId;
|
||||
pendingActivationConversationIdRef.current = null;
|
||||
setIsLoadingMessages(false);
|
||||
if (!normalizedProjectId) {
|
||||
setConversations([]);
|
||||
@@ -326,6 +361,12 @@ export function useEditorAgentConversation({
|
||||
createConversationRequestIdRef.current === requestId &&
|
||||
normalizedProjectIdRef.current === requestedProjectId
|
||||
) {
|
||||
const invalidationRequestId =
|
||||
conversationLoadRequestSequenceRef.current + 1;
|
||||
conversationLoadRequestSequenceRef.current = invalidationRequestId;
|
||||
conversationLoadRequestIdRef.current = invalidationRequestId;
|
||||
pendingActivationConversationIdRef.current = null;
|
||||
setIsLoadingMessages(false);
|
||||
applyConversationDetail(detail);
|
||||
}
|
||||
return detail;
|
||||
@@ -354,7 +395,7 @@ export function useEditorAgentConversation({
|
||||
if (conversationId === activeConversationId) {
|
||||
return null;
|
||||
}
|
||||
return loadConversation(conversationId);
|
||||
return loadConversation(conversationId, { mode: 'activate' });
|
||||
},
|
||||
[activeConversationId, loadConversation],
|
||||
);
|
||||
@@ -364,7 +405,10 @@ export function useEditorAgentConversation({
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
return loadConversation(conversationId, { showLoading: false });
|
||||
return loadConversation(conversationId, {
|
||||
mode: 'refresh',
|
||||
showLoading: false,
|
||||
});
|
||||
}, [loadConversation]);
|
||||
|
||||
const requestCanvasRefreshForMessages = useCallback(
|
||||
@@ -374,9 +418,9 @@ export function useEditorAgentConversation({
|
||||
const toolCall = message.toolCall;
|
||||
return Boolean(
|
||||
toolCall?.externalJobId &&
|
||||
(toolCall.images.length > 0 ||
|
||||
(toolCall.videos?.length ?? 0) > 0 ||
|
||||
(toolCall.audios?.length ?? 0) > 0),
|
||||
(toolCall.images.length > 0 ||
|
||||
(toolCall.videos?.length ?? 0) > 0 ||
|
||||
(toolCall.audios?.length ?? 0) > 0),
|
||||
);
|
||||
})
|
||||
) {
|
||||
@@ -408,7 +452,7 @@ export function useEditorAgentConversation({
|
||||
async (rawText: string, attachments: EditorAgentAttachmentRef[] = []) => {
|
||||
const text = rawText.trim();
|
||||
if (
|
||||
(!text && !attachments.length) ||
|
||||
!text ||
|
||||
isWaitingRef.current ||
|
||||
activeToolCallActionRef.current !== null ||
|
||||
isLoadingConversations ||
|
||||
@@ -541,6 +585,7 @@ export function useEditorAgentConversation({
|
||||
}
|
||||
|
||||
const detail = await loadConversation(conversationId, {
|
||||
mode: 'refresh',
|
||||
showLoading: false,
|
||||
});
|
||||
const updatedMessage = detail.messages.find(
|
||||
@@ -596,7 +641,9 @@ export function useEditorAgentConversation({
|
||||
setConversations(remainingConversations);
|
||||
const nextConversation = remainingConversations[0] ?? null;
|
||||
if (nextConversation) {
|
||||
await loadConversation(nextConversation.conversationId);
|
||||
await loadConversation(nextConversation.conversationId, {
|
||||
mode: 'activate',
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeConversationIdRef.current = null;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
withEditorProjectCanvasRevision,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
function render(ui: Parameters<typeof testingLibraryRender>[0]) {
|
||||
@@ -939,7 +940,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
|
||||
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '生成资源项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
@@ -969,7 +970,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
}));
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
@@ -1043,14 +1044,14 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
}));
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
|
||||
@@ -74,17 +74,12 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
uploadEditorMediaAssetObjectFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasProjectCoverSnapshotModel')
|
||||
>('./ImageCanvasProjectCoverSnapshotModel');
|
||||
return {
|
||||
...actual,
|
||||
createProjectCoverSnapshotBlob: vi.fn(async () => null),
|
||||
};
|
||||
});
|
||||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||||
createProjectCoverSnapshotBlob: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
describe('ImageCanvasEditorView generation integration', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
@@ -415,6 +410,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
project: {
|
||||
projectId: input.projectId,
|
||||
title: '默认项目',
|
||||
canvas: { revision: 1 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
resources: [
|
||||
...projectResources,
|
||||
@@ -482,6 +478,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-generated-dialog',
|
||||
title: '已生成画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -551,6 +548,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-generated-layer-only',
|
||||
title: '已生成画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -605,6 +603,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-uploaded-tagged-layer',
|
||||
title: '标签素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -650,6 +649,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-context-tagged-layer',
|
||||
title: '右键标签素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -700,6 +700,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-publication-dialog',
|
||||
title: '宣发画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -836,6 +837,10 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
const pixelArtToggle = within(generateDialog).getByRole('checkbox', {
|
||||
name: '像素艺术',
|
||||
}) as HTMLInputElement;
|
||||
expect(pixelArtToggle.checked).toBe(false);
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', { name: '生成' }).className,
|
||||
).toContain('platform-button');
|
||||
@@ -847,6 +852,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '一张明亮的拼图主视觉' },
|
||||
});
|
||||
fireEvent.click(pixelArtToggle);
|
||||
fireEvent.click(
|
||||
within(generateDialog).getByRole('button', { name: '生成' }),
|
||||
);
|
||||
@@ -857,6 +863,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect.objectContaining({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
style: 'pixelArt',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
projectId: 'editor-project-default',
|
||||
@@ -865,6 +872,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
const submittedGenerationInputs =
|
||||
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
|
||||
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
|
||||
'pixelArt',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
@@ -1327,6 +1339,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icons-generating',
|
||||
title: '图标素材生成中画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -1699,6 +1712,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: rememberedProjectLayers,
|
||||
resources: rememberedProjectResources,
|
||||
@@ -2334,6 +2348,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: characterReferenceProjectLayers,
|
||||
resources: characterReferenceProjectResources,
|
||||
@@ -2511,6 +2526,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icons',
|
||||
title: '图标素材画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2641,7 +2657,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
referenceImageSrc: 'resource-icon-spec',
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
iconDescriptions: ['返回按钮\n设置按钮'],
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
@@ -2686,6 +2702,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-icon-library-refresh',
|
||||
title: '图标素材刷新画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2831,6 +2848,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId,
|
||||
title: '刷新恢复画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -2873,6 +2891,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId,
|
||||
title: '刷新恢复画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3141,6 +3160,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-character-animation',
|
||||
title: '角色动画画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3228,7 +3248,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
});
|
||||
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
|
||||
|
||||
const characterLayer = screen.getByAltText('画布图片:市场老妇人');
|
||||
// 项目封面快照会在图层落位后触发一次重渲染,同步查询可能正好落进该窗口;
|
||||
// 这里用异步查询容忍这次重渲染,不改变断言语义。
|
||||
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
|
||||
fireEvent.click(characterLayer.closest('button')!);
|
||||
expect(screen.getByText('角色')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||||
@@ -3368,6 +3390,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract',
|
||||
title: 'UI素材提取画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
@@ -3541,6 +3564,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-ui-extract-dismiss',
|
||||
title: 'UI素材提取外部关闭画布',
|
||||
canvas: { revision: 0 },
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createLayerFromAsset,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateCanvasGenerationDialog,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
@@ -631,6 +632,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
composerOpen: false,
|
||||
generatedLayerId: 'layer-generated',
|
||||
imageModel: 'gpt-image-2',
|
||||
style: 'pixelArt',
|
||||
generationStartedAt: 1_771_400_000_000,
|
||||
generationFinishedAt: 1_771_400_004_000,
|
||||
placeholder: {
|
||||
@@ -688,6 +690,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
status: 'generating',
|
||||
generatedLayerId: 'layer-generated',
|
||||
imageModel: 'gpt-image-2',
|
||||
style: 'pixelArt',
|
||||
generationStartedAt: 1_771_400_000_000,
|
||||
generationFinishedAt: 1_771_400_004_000,
|
||||
placeholder: {
|
||||
@@ -704,6 +707,35 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults restored supported image styles to none and drops them from other modes', () => {
|
||||
expect(
|
||||
hydrateCanvasGenerationDialog({
|
||||
id: 'generation-dialog-legacy',
|
||||
mode: 'generate',
|
||||
prompt: '旧任务',
|
||||
status: 'idle',
|
||||
})?.style,
|
||||
).toBe('none');
|
||||
expect(
|
||||
hydrateCanvasGenerationDialog({
|
||||
id: 'generation-dialog-unknown-style',
|
||||
mode: 'character',
|
||||
prompt: '未知风格',
|
||||
status: 'idle',
|
||||
style: 'futureStyle',
|
||||
})?.style,
|
||||
).toBe('none');
|
||||
expect(
|
||||
hydrateCanvasGenerationDialog({
|
||||
id: 'generation-dialog-spec',
|
||||
mode: 'spec',
|
||||
prompt: '规范任务',
|
||||
status: 'idle',
|
||||
style: 'pixelArt',
|
||||
})?.style,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops restored generator references owned by another user', () => {
|
||||
const dialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-owner',
|
||||
|
||||
@@ -537,6 +537,14 @@ export function hydrateCanvasGenerationDialog(
|
||||
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
|
||||
return null;
|
||||
}
|
||||
const style =
|
||||
snapshot.mode === 'generate' ||
|
||||
snapshot.mode === 'character' ||
|
||||
snapshot.mode === 'icon'
|
||||
? snapshot.style === 'pixelArt'
|
||||
? 'pixelArt'
|
||||
: 'none'
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -601,6 +609,7 @@ export function hydrateCanvasGenerationDialog(
|
||||
currentUserId,
|
||||
),
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
style,
|
||||
videoModel:
|
||||
typeof snapshot.videoModel === 'string'
|
||||
? snapshot.videoModel
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
EditorCharacterAnimationGenerationResult,
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
EditorImageGenerationStyle,
|
||||
EditorVideoAspectRatio,
|
||||
EditorVideoModel,
|
||||
EditorVideoResolution,
|
||||
@@ -215,6 +216,7 @@ export type GenerateDialogState = {
|
||||
publicationReferences?: CharacterReferenceImage[];
|
||||
uiDesignSpecReference?: CharacterReferenceImage | null;
|
||||
imageModel?: string;
|
||||
style?: EditorImageGenerationStyle;
|
||||
videoModel?: EditorVideoModel;
|
||||
videoAspectRatio?: EditorVideoAspectRatio;
|
||||
videoResolution?: EditorVideoResolution;
|
||||
@@ -303,9 +305,11 @@ export type CanvasHistoryActionType =
|
||||
export type CanvasHistoryAction = {
|
||||
type: CanvasHistoryActionType;
|
||||
count?: number;
|
||||
layerIds?: string[];
|
||||
};
|
||||
|
||||
export type CanvasHistoryEntry = {
|
||||
entryId: number;
|
||||
snapshot: CanvasHistorySnapshot;
|
||||
action: CanvasHistoryAction;
|
||||
createdAt: number;
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { ContextType } from 'react';
|
||||
import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||
|
||||
@@ -73,7 +78,7 @@ export const defaultEditorProjectResources = [
|
||||
height: 405,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
];
|
||||
] satisfies EditorProjectResourceSnapshot[];
|
||||
|
||||
export const defaultEditorProjectLayers = [
|
||||
{
|
||||
@@ -102,7 +107,31 @@ export const defaultEditorProjectLayers = [
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
];
|
||||
] satisfies EditorProjectLayerSnapshot[];
|
||||
|
||||
export function withEditorProjectCanvasRevision<
|
||||
T extends EditorProjectSnapshot,
|
||||
>(project: T, revision = 0): T {
|
||||
if (typeof project.canvas?.revision === 'number') {
|
||||
return project;
|
||||
}
|
||||
return {
|
||||
...project,
|
||||
canvas: {
|
||||
canvasId:
|
||||
project.canvas?.canvasId ?? `${project.projectId}:canvas:default`,
|
||||
projectId: project.projectId,
|
||||
title: project.canvas?.title ?? '默认画布',
|
||||
viewport: project.canvas?.viewport ?? project.viewport,
|
||||
layers: project.canvas?.layers ?? project.layers,
|
||||
revision,
|
||||
layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0,
|
||||
backgroundColor: project.canvas?.backgroundColor,
|
||||
createdAt: project.canvas?.createdAt,
|
||||
updatedAt: project.canvas?.updatedAt ?? project.updatedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultEditorAssetLibraryAssets = [
|
||||
{
|
||||
@@ -235,14 +264,14 @@ export function setupImageCanvasEditorViewTestLifecycle({
|
||||
window.sessionStorage.clear();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
immediateAsync(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: defaultEditorProjectLayers,
|
||||
resources: defaultEditorProjectResources,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
})),
|
||||
);
|
||||
loadEditorAssetLibraryMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
immediateAsync,
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
withEditorProjectCanvasRevision,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
type EditorAgentListConversations =
|
||||
@@ -111,6 +112,7 @@ const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
@@ -136,6 +138,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
createEditorAsset: createEditorAssetMock,
|
||||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
|
||||
deleteEditorAsset: deleteEditorAssetMock,
|
||||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
@@ -322,6 +325,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
confirmEditorAgentToolCallMock.mockReset();
|
||||
cancelEditorAgentToolCallMock.mockReset();
|
||||
splitEditorIconSpritesheetMock.mockReset();
|
||||
splitEditorIconSpritesheetMock.mockImplementation(
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
getPlatformProfileDashboardMock.mockResolvedValue({
|
||||
walletBalance: 1234,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -451,14 +458,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-guide',
|
||||
title: '空白项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -499,14 +508,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('opens the requested canvas startup tool from the project query once', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
loadEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-music',
|
||||
title: '音乐项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
@@ -522,7 +533,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(window.location.search).toBe('?projectid=editor-project-music');
|
||||
});
|
||||
|
||||
it('shows the loaded project title and a topbar entry back to projects', async () => {
|
||||
it('flushes project persistence before returning from the topbar', async () => {
|
||||
const onPopState = vi.fn();
|
||||
window.addEventListener('popstate', onPopState);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(
|
||||
@@ -532,6 +545,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(projectLink.getAttribute('href')).toBe('/project');
|
||||
expect(screen.queryByRole('heading', { name: '图片编辑器' })).toBeNull();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
|
||||
fireEvent.click(projectLink);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/project');
|
||||
expect(onPopState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
|
||||
it('opens login modal when the asset library is unauthorized', async () => {
|
||||
@@ -2041,6 +2064,160 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
const pendingSplitButton = await screen.findByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
|
||||
fireEvent.click(pendingSplitButton);
|
||||
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.resolve({
|
||||
resourceId: 'resource-confirmed-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await persistedResource.promise;
|
||||
});
|
||||
|
||||
const splitButton = await screen.findByRole('button', {
|
||||
name: '拆分图集',
|
||||
});
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-default',
|
||||
sourceResourceId: 'resource-confirmed-atlas',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back the asset kind and resource when persistence fails', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.reject(new Error('资源类型写入失败'));
|
||||
try {
|
||||
await persistedResource.promise;
|
||||
} catch {
|
||||
// The component consumes the rejection and restores the prior layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:未知',
|
||||
});
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back when the resource response keeps the old asset kind', async () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-stale-icon-spec',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spec',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText(
|
||||
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists an undone asset kind and ignores the older pending response', async () => {
|
||||
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
@@ -2119,6 +2296,167 @@ describe('ImageCanvasEditorView', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => failedUndoResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
failedUndoResource.reject(new Error('撤销后资源写入失败'));
|
||||
try {
|
||||
await failedUndoResource.promise;
|
||||
} catch {
|
||||
// The latest failure must restore the server-confirmed original layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const undoButton = screen.getByRole('button', { name: '撤销' });
|
||||
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(undoButton);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.resolve({
|
||||
resourceId: 'resource-stale-character-after-failed-undo',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await staleCharacterResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character-after-failed-undo',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the newer asset kind pending when an older request fails', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const currentAtlasResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => currentAtlasResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.reject(new Error('旧请求失败'));
|
||||
try {
|
||||
await staleCharacterResource.promise;
|
||||
} catch {
|
||||
// The stale rejection must not affect the newer request state.
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
|
||||
).toBeTruthy();
|
||||
const stillPendingButton = screen.getByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText(/素材类型保存失败/u)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
currentAtlasResource.resolve({
|
||||
resourceId: 'resource-current-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await currentAtlasResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '拆分图集' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2251,8 +2589,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
object_image_id: 'source-image',
|
||||
prompt: '把图片换成像素风',
|
||||
},
|
||||
displayArgs:
|
||||
createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
images: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -2273,8 +2610,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
status: 'not_completed',
|
||||
externalJobId: 'task-edit-canvas-1',
|
||||
args: {},
|
||||
displayArgs:
|
||||
createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'),
|
||||
images: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -2335,9 +2671,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientX: 400,
|
||||
clientY: 280,
|
||||
});
|
||||
expect(world.style.transform).toBe(
|
||||
'translate(-80px, -120px) scale(0.5)',
|
||||
);
|
||||
expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '当前缩放比例 100%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import {
|
||||
createEditorAsset,
|
||||
@@ -23,9 +26,7 @@ import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform'
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
PlatformRechargePaymentResultDialog,
|
||||
@@ -310,16 +311,21 @@ export function ImageCanvasEditorView({
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<
|
||||
(action: CanvasHistoryAction) => void
|
||||
>(() => {});
|
||||
const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>(
|
||||
() => {},
|
||||
);
|
||||
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
|
||||
(matchesLayer: (layer: CanvasLayer) => boolean) => void
|
||||
>(() => {});
|
||||
const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>(
|
||||
() => {},
|
||||
);
|
||||
const persistRestoredAssetKindRef = useRef<
|
||||
(layer: CanvasLayer, historyEntryId?: number) => void
|
||||
>(() => {});
|
||||
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
|
||||
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
|
||||
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
|
||||
new Map<string, Set<number>>(),
|
||||
);
|
||||
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -790,8 +796,8 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
const canvasHistoryEffects = useMemo(
|
||||
() => ({
|
||||
persistRestoredAssetKind: (layer: CanvasLayer) =>
|
||||
persistRestoredAssetKindRef.current(layer),
|
||||
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
|
||||
persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -801,6 +807,7 @@ export function ImageCanvasEditorView({
|
||||
getCanvasHistorySnapshot,
|
||||
captureCanvasHistory,
|
||||
discardCanvasHistoryEntriesContainingLayer,
|
||||
discardCanvasHistoryEntriesById,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
} = useCanvasHistory({
|
||||
@@ -814,6 +821,8 @@ export function ImageCanvasEditorView({
|
||||
discardCanvasHistoryEntriesContainingLayer;
|
||||
const [historyFeedback, setHistoryFeedback] =
|
||||
useState<CanvasHistoryFeedback | null>(null);
|
||||
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
|
||||
useState<ReadonlySet<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
if (!historyFeedback) {
|
||||
return;
|
||||
@@ -1154,12 +1163,14 @@ export function ImageCanvasEditorView({
|
||||
isProjectReady,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
flushProjectPersistence,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasSize,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
@@ -1167,6 +1178,11 @@ export function ImageCanvasEditorView({
|
||||
openEditorLoginModal,
|
||||
onProjectAccessLost,
|
||||
});
|
||||
const returnToProjects = useCallback(async () => {
|
||||
await flushProjectPersistence();
|
||||
replaceAppHistoryPath('/project');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, [flushProjectPersistence]);
|
||||
const setEditorProjectContextId = useImageCanvasContextStore(
|
||||
(state) => state.setProjectId,
|
||||
);
|
||||
@@ -1192,18 +1208,105 @@ export function ImageCanvasEditorView({
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
}, [applyGeneratedProjectSnapshot, projectId]);
|
||||
const setLayerAssetKindPersistencePending = useCallback(
|
||||
(layerId: string, isPending: boolean) => {
|
||||
const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current);
|
||||
if (isPending) {
|
||||
nextLayerIds.add(layerId);
|
||||
} else {
|
||||
nextLayerIds.delete(layerId);
|
||||
}
|
||||
persistingAssetKindLayerIdsRef.current = nextLayerIds;
|
||||
setPersistingAssetKindLayerIds(nextLayerIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const persistUpdatedLayerResource = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
(
|
||||
layer: CanvasLayer,
|
||||
rollbackLayer?: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => {
|
||||
const requestVersion =
|
||||
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
|
||||
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
|
||||
const confirmedRollbackLayer = rollbackLayer
|
||||
? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer)
|
||||
: undefined;
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindRollbackLayerByIdRef.current.set(
|
||||
layer.id,
|
||||
confirmedRollbackLayer,
|
||||
);
|
||||
if (historyEntryId !== undefined) {
|
||||
const historyEntryIds = new Set(
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id),
|
||||
);
|
||||
historyEntryIds.add(historyEntryId);
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.set(
|
||||
layer.id,
|
||||
historyEntryIds,
|
||||
);
|
||||
}
|
||||
setLayerAssetKindPersistencePending(layer.id, true);
|
||||
}
|
||||
const rollbackLatestRequest = (error: unknown) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion ||
|
||||
!confirmedRollbackLayer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
const historyEntryIds =
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id);
|
||||
if (historyEntryIds) {
|
||||
discardCanvasHistoryEntriesById(historyEntryIds);
|
||||
}
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
const reason =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message.trim()
|
||||
: '请稍后重试';
|
||||
setHistoryFeedback((currentFeedback) => ({
|
||||
id: (currentFeedback?.id ?? 0) + 1,
|
||||
tone: 'warning',
|
||||
text: `素材类型保存失败,已恢复原标签:${reason}`,
|
||||
}));
|
||||
};
|
||||
if (!projectId) {
|
||||
rollbackLatestRequest(new Error('项目尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
|
||||
if (!imageSrc) {
|
||||
rollbackLatestRequest(new Error('素材图片尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const requestVersion =
|
||||
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
|
||||
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
|
||||
void createEditorProjectResource(projectId, {
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
@@ -1220,30 +1323,64 @@ export function ImageCanvasEditorView({
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
}).then((resource) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
})
|
||||
.then((resource) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirmedRollbackLayer &&
|
||||
(resource.assetKind !== layer.assetKind ||
|
||||
!resource.resourceId.trim())
|
||||
) {
|
||||
rollbackLatestRequest(new Error('服务器未确认新的素材类型'));
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
});
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
}
|
||||
})
|
||||
.catch(rollbackLatestRequest);
|
||||
},
|
||||
[projectId],
|
||||
[
|
||||
discardCanvasHistoryEntriesById,
|
||||
projectId,
|
||||
setLayerAssetKindPersistencePending,
|
||||
],
|
||||
);
|
||||
persistRestoredAssetKindRef.current = persistUpdatedLayerResource;
|
||||
persistRestoredAssetKindRef.current = (layer, historyEntryId) => {
|
||||
const rollbackLayer = layersRef.current.find(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
);
|
||||
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
|
||||
};
|
||||
const {
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -1368,7 +1505,9 @@ export function ImageCanvasEditorView({
|
||||
if (!projectId || tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
|
||||
const warning = tasks
|
||||
.find((task) => task.warning?.trim())
|
||||
?.warning?.trim();
|
||||
if (warning) {
|
||||
showGenerationWarning(warning);
|
||||
}
|
||||
@@ -1819,7 +1958,11 @@ export function ImageCanvasEditorView({
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({ type: 'change-asset-kind', count: 1 });
|
||||
const historyEntryId = captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: 1,
|
||||
layerIds: [layerId],
|
||||
});
|
||||
const nextLayer = {
|
||||
...targetLayer,
|
||||
assetKind,
|
||||
@@ -1834,7 +1977,7 @@ export function ImageCanvasEditorView({
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layerId ? nextLayer : currentLayer,
|
||||
);
|
||||
persistUpdatedLayerResource(nextLayer);
|
||||
persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
@@ -1888,9 +2031,7 @@ export function ImageCanvasEditorView({
|
||||
return;
|
||||
}
|
||||
captureCanvasHistory({
|
||||
type: targetLayerIds.length
|
||||
? 'move-image'
|
||||
: 'move-generation-result',
|
||||
type: targetLayerIds.length ? 'move-image' : 'move-generation-result',
|
||||
count: targetLayerIds.length || targetDialogIds.length,
|
||||
});
|
||||
if (targetLayerIds.length) {
|
||||
@@ -2197,6 +2338,7 @@ export function ImageCanvasEditorView({
|
||||
onRequestWalletDetails: loadRechargeCenter,
|
||||
onRecharge: openAccountPaymentModal,
|
||||
onOpenWalletLedger: openWalletLedgerPanel,
|
||||
onReturnToProjects: returnToProjects,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -2243,6 +2385,7 @@ export function ImageCanvasEditorView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
persistingAssetKindLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -2293,7 +2436,12 @@ export function ImageCanvasEditorView({
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onSplitIconSpritesheet: splitSelectedIconSpritesheet,
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => {
|
||||
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
|
||||
return;
|
||||
}
|
||||
void splitSelectedIconSpritesheet(layer);
|
||||
},
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
|
||||
|
||||
@@ -30,9 +30,7 @@ import {
|
||||
createSpecDialogDraft,
|
||||
createUiDesignGenerationDialogDraft,
|
||||
createVideoGenerationDialogDraft,
|
||||
formatIconDescriptionsText,
|
||||
hideGeneratedLayerComposerAfterBlur,
|
||||
parseIconDescriptionsText,
|
||||
updateCharacterAnimationDurationPanel,
|
||||
updateIconDescriptionsTextInDialog,
|
||||
updateSpecFormDialogValue,
|
||||
@@ -69,6 +67,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
mode: 'generate',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
style: 'none',
|
||||
placeholder: {
|
||||
x: -312,
|
||||
y: -332,
|
||||
@@ -156,6 +155,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'character',
|
||||
style: 'none',
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
@@ -176,6 +176,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
mode: 'icon',
|
||||
style: 'none',
|
||||
imageModel: 'unknown-model',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
@@ -233,10 +234,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
imageSize: '1K',
|
||||
uiDesignSpecReference: null,
|
||||
placeholder: {
|
||||
x: -64,
|
||||
y: 56,
|
||||
width: 1088,
|
||||
height: 608,
|
||||
x: -32,
|
||||
y: 72,
|
||||
width: 1024,
|
||||
height: 576,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -609,6 +610,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
style: 'pixelArt',
|
||||
},
|
||||
mode: 'redraw',
|
||||
}),
|
||||
@@ -619,12 +621,13 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
style: 'none',
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
width: 1376,
|
||||
width: 1365,
|
||||
height: 2048,
|
||||
originalWidth: 1376,
|
||||
originalWidth: 1365,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
});
|
||||
@@ -662,9 +665,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
placeholder: {
|
||||
x: 472,
|
||||
y: 140,
|
||||
width: 1376,
|
||||
width: 1365,
|
||||
height: 2048,
|
||||
originalWidth: 1376,
|
||||
originalWidth: 1365,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
});
|
||||
@@ -1174,17 +1177,9 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
prompt: '剑\n盾、药水',
|
||||
iconDescriptions: ['剑', '盾', '药水'],
|
||||
iconDescriptions: [],
|
||||
}),
|
||||
);
|
||||
expect(parseIconDescriptionsText('剑,盾\n药水/宝箱|钥匙')).toEqual([
|
||||
'剑',
|
||||
'盾',
|
||||
'药水',
|
||||
'宝箱',
|
||||
'钥匙',
|
||||
]);
|
||||
expect(formatIconDescriptionsText(['剑', '盾'])).toBe('剑\n盾');
|
||||
});
|
||||
|
||||
it('updates character animation duration and composer visibility', () => {
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
inferEditorImageAspectRatio,
|
||||
inferEditorImageSizeLabel,
|
||||
@@ -87,18 +86,6 @@ function resetFailedGenerationPanel(panel: QuickEditPanelState) {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIconDescriptionsText(text: string): string[] {
|
||||
return text
|
||||
.split(/[\r\n,,、;;/|]+/u)
|
||||
.map((description) => description.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, ICON_DESCRIPTION_LIMIT);
|
||||
}
|
||||
|
||||
export function formatIconDescriptionsText(descriptions: string[]): string {
|
||||
return descriptions.join('\n');
|
||||
}
|
||||
|
||||
function isSeedanceVideoModel(model: string | undefined) {
|
||||
const resolvedModel = model ?? DEFAULT_VIDEO_MODEL;
|
||||
return (
|
||||
@@ -196,6 +183,7 @@ export function createGenerateDialogDraft({
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
style: 'none',
|
||||
imageModel: DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
@@ -269,6 +257,7 @@ export function createCharacterGenerationDialogDraft({
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
style: 'none',
|
||||
characterSpecReference: null,
|
||||
characterReferences: [],
|
||||
imageModel: normalizedImageModel,
|
||||
@@ -307,6 +296,7 @@ export function createIconGenerationDialogDraft({
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
style: 'none',
|
||||
iconSpecReference: null,
|
||||
generationReferences: [],
|
||||
iconDescriptions: [],
|
||||
@@ -917,16 +907,16 @@ function restoreIconDescriptionsFromLayer(
|
||||
sourceLayer: CanvasLayer,
|
||||
sourceDialog?: CanvasGenerationDialogState | null,
|
||||
): Omit<CanvasGenerationDialogState, 'id'> {
|
||||
const descriptions = parseIconDescriptionsText(
|
||||
const prompt =
|
||||
findGenerationInputFieldValue(sourceLayer, ['素材描述']) ??
|
||||
sourceDialog?.prompt ??
|
||||
'',
|
||||
);
|
||||
(sourceDialog?.prompt || sourceDialog?.iconDescriptions?.join('\n')) ??
|
||||
'';
|
||||
const normalizedPrompt = prompt.trim();
|
||||
return {
|
||||
...draft,
|
||||
prompt: descriptions.join('\n'),
|
||||
iconDescriptions: descriptions.length
|
||||
? descriptions
|
||||
prompt,
|
||||
iconDescriptions: normalizedPrompt
|
||||
? [normalizedPrompt]
|
||||
: (sourceDialog?.iconDescriptions ?? draft.iconDescriptions),
|
||||
iconSpecReference:
|
||||
sourceDialog?.iconSpecReference ?? draft.iconSpecReference,
|
||||
@@ -1579,7 +1569,7 @@ export function updateIconDescriptionsTextInDialog(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
prompt: value,
|
||||
iconDescriptions: parseIconDescriptionsText(value),
|
||||
iconDescriptions: [],
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ function ImageOptionsHarness({
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="当前占位">
|
||||
@@ -66,6 +67,67 @@ function ImageOptionsHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
it.each(['generate', 'character', 'icon'] as const)(
|
||||
'shows and updates the pixel art style for %s generation',
|
||||
(mode) => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode,
|
||||
prompt: '',
|
||||
status: 'failed',
|
||||
errorMessage: '旧错误',
|
||||
style: 'none',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole('checkbox', {
|
||||
name: '像素艺术',
|
||||
}) as HTMLInputElement;
|
||||
const modelButton = screen.getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
});
|
||||
expect(toggle.checked).toBe(false);
|
||||
expect(
|
||||
toggle.compareDocumentPosition(modelButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).not.toBe(0);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(screen.getByLabelText('当前风格').textContent).toBe('pixelArt');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['spec', 'quick-edit', 'ui-design', 'publication'] as const)(
|
||||
'does not show the pixel art style for %s generation',
|
||||
(mode) => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode,
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '像素艺术' }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
|
||||
@@ -137,6 +137,10 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isQuickEdit = dialog.mode === 'quick-edit';
|
||||
const supportsImageStyle =
|
||||
dialog.mode === 'generate' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon';
|
||||
const normalizedLockedModel = lockedModel
|
||||
? normalizeEditorImageModel(lockedModel)
|
||||
: null;
|
||||
@@ -306,6 +310,21 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{supportsImageStyle ? (
|
||||
<label className="image-canvas-editor__image-style-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dialog.style === 'pixelArt'}
|
||||
disabled={isGenerating}
|
||||
onChange={(event) =>
|
||||
updateDialog({
|
||||
style: event.target.checked ? 'pixelArt' : 'none',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>像素艺术</span>
|
||||
</label>
|
||||
) : null}
|
||||
{includeModel ? (
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -108,18 +108,18 @@ describe('ImageCanvasGenerationModel', () => {
|
||||
[IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 688],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 688, 1024],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 608, 1088],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1088, 608],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1376],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1376, 2048],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048],
|
||||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152],
|
||||
] as const)(
|
||||
'%s 的 %s %s 占位尺寸与 provider 直接请求尺寸一致',
|
||||
'%s 的 %s %s 占位尺寸与统一业务交付尺寸一致',
|
||||
(model, imageSize, aspectRatio, width, height) => {
|
||||
expect(
|
||||
resolveEditorImageGenerationPixelSize({
|
||||
|
||||
@@ -83,7 +83,6 @@ export const SPEC_GENERATION_COST =
|
||||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][
|
||||
SPEC_GENERATION_IMAGE_SIZE
|
||||
];
|
||||
export const ICON_DESCRIPTION_LIMIT = 100;
|
||||
export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = {
|
||||
gameName: '',
|
||||
gameCategories: '',
|
||||
@@ -173,38 +172,8 @@ function resolveImageFrameSizeFromRatio({
|
||||
};
|
||||
}
|
||||
|
||||
function alignImageDimensionUp(value: number, alignment = 16) {
|
||||
return Math.ceil(value / alignment) * alignment;
|
||||
}
|
||||
|
||||
function resolveGptImage2FrameSize({
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
}: {
|
||||
aspectRatio: string | null | undefined;
|
||||
imageSize: string | null | undefined;
|
||||
}): EditorImageFrameSize {
|
||||
const requested = resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
|
||||
let width = requested.width;
|
||||
let height = requested.height;
|
||||
const pixels = width * height;
|
||||
|
||||
// 中文注释:gpt-image-2 要求总像素不少于 655360,且宽高均为 16 的倍数。
|
||||
// 这里只把用户选择的 K 档和比例转换为 provider 可直接生成的真实尺寸,不做生成后放大。
|
||||
if (pixels < 655_360) {
|
||||
const scale = Math.sqrt(655_360 / pixels);
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
}
|
||||
|
||||
return {
|
||||
width: alignImageDimensionUp(width),
|
||||
height: alignImageDimensionUp(height),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveEditorImageGenerationPixelSize({
|
||||
model,
|
||||
model: _model,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
}: {
|
||||
@@ -212,9 +181,6 @@ export function resolveEditorImageGenerationPixelSize({
|
||||
aspectRatio: string | null | undefined;
|
||||
imageSize: string | null | undefined;
|
||||
}): EditorImageFrameSize {
|
||||
if (model?.trim() === IMAGE_MODEL_GPT_IMAGE_2) {
|
||||
return resolveGptImage2FrameSize({ aspectRatio, imageSize });
|
||||
}
|
||||
return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
|
||||
}
|
||||
|
||||
@@ -582,6 +548,28 @@ export function getEditorImageModelDisplayName(
|
||||
);
|
||||
}
|
||||
|
||||
export function getEditorGenerationModelDisplayName(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
const normalizedModel = model?.trim();
|
||||
if (normalizedModel === BACKGROUND_MUSIC_MODEL_SUNO) {
|
||||
return 'Suno';
|
||||
}
|
||||
const soundEffectLabel = EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
|
||||
(option) => option.value === normalizedModel,
|
||||
)?.label;
|
||||
if (soundEffectLabel) {
|
||||
return soundEffectLabel;
|
||||
}
|
||||
const videoLabel = EDITOR_VIDEO_MODEL_OPTIONS.find(
|
||||
(option) => option.value === normalizedModel,
|
||||
)?.label;
|
||||
if (videoLabel) {
|
||||
return videoLabel;
|
||||
}
|
||||
return getEditorImageModelDisplayName(normalizedModel);
|
||||
}
|
||||
|
||||
export function buildQuickEditModelOptions(currentModel: string) {
|
||||
void currentModel;
|
||||
const options = [...QUICK_EDIT_MODEL_OPTIONS];
|
||||
@@ -1292,17 +1280,17 @@ export function isCanvasGenerationDialog(
|
||||
): dialog is CanvasGenerationDialogState {
|
||||
return Boolean(
|
||||
dialog?.id &&
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
input: {
|
||||
prompt: '一张发光主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
style: 'none',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
},
|
||||
@@ -193,6 +194,24 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send image style from an incomplete quick-edit snapshot', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
mode: 'quick-edit',
|
||||
prompt: '把当前图改成雨天',
|
||||
status: 'idle',
|
||||
style: 'pixelArt',
|
||||
},
|
||||
layers: [],
|
||||
nextGeneratedIndex: 4,
|
||||
});
|
||||
|
||||
expect(plan.kind).toBe('image');
|
||||
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
|
||||
'style',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds spec generation plans with reference prompt semantics', () => {
|
||||
const plan = buildImageGenerationSubmissionPlan({
|
||||
dialog: {
|
||||
@@ -294,6 +313,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
prompt: ' 白发骑士 ',
|
||||
status: 'idle',
|
||||
imageModel: 'gpt-image-2',
|
||||
style: 'pixelArt',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
characterSpecReference: {
|
||||
@@ -324,6 +344,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
model: 'gpt-image-2',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
style: 'pixelArt',
|
||||
aspectRatio: '2:3',
|
||||
imageSize: '2K',
|
||||
referenceImageSrcs: [
|
||||
@@ -432,6 +453,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
mode: 'publication',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
style: 'pixelArt',
|
||||
publicationGameInfo: {
|
||||
gameName: ' 重庆洪崖洞火锅 ',
|
||||
gameCategories: '抓大鹅、休闲、治愈、手绘风',
|
||||
@@ -659,12 +681,13 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('builds icon spritesheet plans with trimmed descriptions and references', () => {
|
||||
it('builds icon spritesheet plans with the complete prompt and references', () => {
|
||||
const plan = buildIconSpritesheetGenerationSubmissionPlan({
|
||||
mode: 'icon',
|
||||
prompt: ' 返回按钮 \n\n设置按钮',
|
||||
prompt: ' 返回按钮\n\n设置按钮 ',
|
||||
status: 'idle',
|
||||
imageModel: 'gpt-image-2',
|
||||
style: 'pixelArt',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
assetLabel: ' 冒险游戏图标 ',
|
||||
@@ -687,19 +710,20 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
|
||||
expect(plan).toEqual({
|
||||
ok: true,
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
iconDescriptions: ['返回按钮\n\n设置按钮'],
|
||||
input: {
|
||||
referenceImageSrc: 'data:image/png;base64,spec',
|
||||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||||
iconDescriptions: ['返回按钮\n\n设置按钮'],
|
||||
model: 'gpt-image-2',
|
||||
screenColor: 'auto',
|
||||
segModel: 'birefnet',
|
||||
style: 'pixelArt',
|
||||
aspectRatio: '3:2',
|
||||
imageSize: '2K',
|
||||
},
|
||||
generationInputs: {
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||||
fields: [{ title: '素材描述', value: '返回按钮\n\n设置按钮' }],
|
||||
references: [
|
||||
{
|
||||
title: '图标规范',
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
DEFAULT_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_SOUND,
|
||||
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
normalizeEditorImageModel,
|
||||
resolveCharacterAnimationSourceImageSrc,
|
||||
@@ -354,6 +353,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
model: imageModel,
|
||||
screenColor,
|
||||
segModel,
|
||||
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
||||
@@ -564,6 +564,9 @@ export function buildImageGenerationSubmissionPlan({
|
||||
input: {
|
||||
prompt: normalizedPrompt,
|
||||
model: imageModel,
|
||||
...(dialog.mode === 'generate'
|
||||
? { style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none' }
|
||||
: {}),
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(dialog.generationReferences?.length
|
||||
@@ -592,17 +595,13 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
dialog: GenerateDialogState,
|
||||
nextGeneratedIndex = 1,
|
||||
): IconSpritesheetGenerationSubmissionPlan {
|
||||
const iconDescriptionSource = dialog.prompt.trim()
|
||||
? dialog.prompt.split(/[\r\n,,、;;/|]+/u)
|
||||
: (dialog.iconDescriptions ?? []).some((description) =>
|
||||
description.trim(),
|
||||
)
|
||||
? (dialog.iconDescriptions ?? [])
|
||||
: [];
|
||||
const iconDescriptions = iconDescriptionSource
|
||||
.map((description) => description.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, ICON_DESCRIPTION_LIMIT);
|
||||
const normalizedPrompt =
|
||||
dialog.prompt.trim() ||
|
||||
(dialog.iconDescriptions ?? [])
|
||||
.map((description) => description.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const iconDescriptions = normalizedPrompt ? [normalizedPrompt] : [];
|
||||
|
||||
if (!dialog.iconSpecReference) {
|
||||
return {
|
||||
@@ -639,6 +638,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
|
||||
model: rememberImageModel,
|
||||
screenColor,
|
||||
segModel,
|
||||
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
},
|
||||
|
||||
@@ -183,6 +183,22 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the current asset kind for layers outside the history action target', () => {
|
||||
const targetLayer = createLayer({ assetKind: 'icon-spritesheet', x: 10 });
|
||||
const currentLayer = createLayer({ assetKind: 'character', x: 90 });
|
||||
|
||||
const merged = mergeCanvasHistorySnapshotForRestore({
|
||||
current: createSnapshot([currentLayer]),
|
||||
target: createSnapshot([targetLayer]),
|
||||
assetKindLayerIds: new Set(),
|
||||
});
|
||||
|
||||
expect(merged.layers[0]).toMatchObject({
|
||||
assetKind: 'character',
|
||||
x: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the complete target layer after that layer was deleted', () => {
|
||||
const targetLayer = createLayer({
|
||||
resourceId: 'deleted-resource',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user