Merge remote-tracking branch 'origin/master' into fix/multi-select
Project CI / Native shell tests (pull_request) Successful in 11m34s
Project CI / Frontend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 46s
Project CI / Backend tests (pull_request) Failing after 2m37s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-07-31 16:18:47 +08:00
677 changed files with 413609 additions and 5097 deletions
@@ -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(() => {
@@ -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" />
发送
@@ -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])
@@ -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