feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View File

@@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { ImageCanvas } from '@/pages/ImageCanvas';
import { useAuthStore } from '@/stores/auth';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAssistantDeltaEvent,
DesignConversation,
DesignGenerationQuote,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignWorkspace,
@@ -20,6 +22,7 @@ const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
const updateImageWorkspaceGenerationQuoteMock = vi.hoisted(() => vi.fn());
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
const saveImageWorkspaceAssetMock = vi.hoisted(() => vi.fn());
@@ -67,6 +70,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
confirmImageWorkspaceGenerationMock(...args)
),
updateImageWorkspaceGenerationQuote: (...args: unknown[]) => (
updateImageWorkspaceGenerationQuoteMock(...args)
),
openImageWorkspaceTaskEvents: (...args: unknown[]) => (
openImageWorkspaceTaskEventsMock(...args)
),
@@ -139,6 +145,55 @@ function conversationFixture(): DesignConversation {
medium: 'image',
briefVersion: 1,
briefSummary: '夜色中的机械城堡角色海报',
finalPrompt: '夜色中的机械城堡角色海报,电影感构图。',
promptMode: 'guided',
generationParameters: {
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
generationOptions: {
models: [{
value: 'image-model-one',
label: '图片模型一',
default: true,
disabled: false,
multiplier: 1,
}],
resolutions: [{
value: '1024x1024',
label: '1024 × 1024',
default: true,
disabled: false,
multiplier: 1,
}, {
value: '2048x2048',
label: '2048 × 2048',
default: false,
disabled: false,
multiplier: 1.5,
}],
durations: [],
aspectRatios: [{
value: '1:1',
label: '1:1',
default: true,
disabled: false,
multiplier: 1,
}, {
value: '16:9',
label: '16:9',
default: false,
disabled: false,
multiplier: 1,
}],
},
pricing: {
schema: 'design-pricing-v2',
amount: 1,
rounding: 'ceil',
},
quotedDesignPoints: 1,
expiresAt: '2026-07-31T11:00:00Z',
},
@@ -189,11 +244,28 @@ describe('ImageCanvas Workspace-first design experience', () => {
beforeEach(() => {
vi.clearAllMocks();
useImageWorkspaceStore.getState().reset();
useImagePromptMuseumStore.getState().clearPendingPrompt();
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
updateImageWorkspaceGenerationQuoteMock.mockImplementation(async (
_workspaceId: string,
_quoteId: string,
finalPrompt: string,
generationParameters: DesignGenerationQuote['generationParameters'],
) => ({
...conversationFixture().messages[1].generationQuote!,
finalPrompt,
generationParameters,
quotedDesignPoints: 3,
pricing: {
schema: 'design-pricing-v2',
amount: 3,
rounding: 'ceil',
},
}));
confirmImageWorkspaceGenerationMock.mockResolvedValue({
...conversationFixture(),
turnRevision: 2,
@@ -301,13 +373,115 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
expect(screen.getByTestId('design-task-list')).toHaveTextContent('生成内容');
expect(screen.queryByText('图片与视频任务统一展示')).not.toBeInTheDocument();
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成结果' }))
const taskCard = screen.getByTestId('design-task-task-one');
expect(taskCard).toHaveTextContent('生图');
expect(taskCard.querySelector('time')).toHaveAttribute('datetime', taskFixture.createdAt);
expect(taskCard).not.toHaveTextContent('已完成');
expect(screen.queryByRole('button', { name: '下载原图' })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成缩略图' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'));
expect(screen.queryByText('生成设置')).not.toBeInTheDocument();
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
});
it('keeps task cards fixed-size and truncates long descriptions', async () => {
const longDescription = '夜色中的机械城堡角色海报,加入冷暖对比和电影感轮廓光效果';
const compactDescription = `${Array.from(longDescription).slice(0, 24).join('')}`;
fetchImageWorkspaceTasksMock.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-long-description',
briefSummary: longDescription,
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-long-description');
expect(taskCard).toHaveClass('h-20', 'w-full');
expect(within(taskCard).getByRole('button')).toHaveClass('h-full');
expect(taskCard).toHaveTextContent(compactDescription);
expect(taskCard).not.toHaveTextContent(longDescription);
});
it('edits the final prompt and server-provided options before re-quoting', async () => {
const requote = deferred<DesignGenerationQuote>();
updateImageWorkspaceGenerationQuoteMock.mockReset();
updateImageWorkspaceGenerationQuoteMock.mockReturnValueOnce(requote.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const finalPrompt = await screen.findByTestId('design-quote-final-prompt');
fireEvent.change(finalPrompt, { target: { value: '用户最新编辑后的完整提示词' } });
fireEvent.change(screen.getByTestId('design-quote-resolution'), {
target: { value: '2048x2048' },
});
fireEvent.change(screen.getByTestId('design-quote-aspect-ratio'), {
target: { value: '16:9' },
});
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledWith(
'workspace-cloud',
'quote-one',
'用户最新编辑后的完整提示词',
{
model: 'image-model-one',
resolution: '2048x2048',
aspectRatio: '16:9',
durationSeconds: null,
},
));
expect(screen.getByRole('button', { name: '重新报价中' })).toBeDisabled();
requote.resolve({
...conversationFixture().messages[1].generationQuote!,
finalPrompt: '用户最新编辑后的完整提示词',
generationParameters: {
model: 'image-model-one',
resolution: '2048x2048',
aspectRatio: '16:9',
durationSeconds: null,
},
quotedDesignPoints: 3,
pricing: { schema: 'design-pricing-v2', amount: 3, rounding: 'ceil' },
});
await waitFor(() => expect(screen.getByTestId('design-quote-quote-one'))
.toHaveTextContent('3 设计点'));
expect(screen.getByTestId('design-quote-final-prompt'))
.toHaveValue('用户最新编辑后的完整提示词');
const quoteCard = screen.getByTestId('design-quote-quote-one');
expect(quoteCard).toHaveTextContent('报价 3 设计点');
expect(quoteCard).toHaveTextContent('继续调整');
expect(quoteCard).toHaveTextContent('清晰度');
expect(quoteCard).toHaveTextContent('画幅');
expect(quoteCard).not.toHaveTextContent('提示词模式guided');
expect(quoteCard).not.toHaveTextContent('夜色中的机械城堡角色海报');
expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled();
});
it('offers an actionable retry when re-quoting fails', async () => {
updateImageWorkspaceGenerationQuoteMock.mockRejectedValueOnce(
new ImageWorkspaceApiError(409, 'generation_quote_invalid', '当前生成方案已失效'),
);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.change(await screen.findByTestId('design-quote-final-prompt'), {
target: { value: '需要重新报价的提示词' },
});
const quoteCard = screen.getByTestId('design-quote-quote-one');
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalled());
expect(await within(quoteCard).findByRole('alert')).toHaveTextContent('当前生成方案已失效');
expect(within(quoteCard).getByText(/请点击“重试报价”/)).toBeInTheDocument();
expect(within(quoteCard).getByRole('button', { name: '重试报价' })).toBeEnabled();
expect(within(quoteCard).getByRole('button', { name: '继续调整' })).toBeEnabled();
expect(within(quoteCard).getByRole('button', { name: '等待报价更新' })).toBeDisabled();
fireEvent.click(within(quoteCard).getByRole('button', { name: '重试报价' }));
await waitFor(() => expect(updateImageWorkspaceGenerationQuoteMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByRole('button', { name: '确认并开始生成' })).toBeEnabled());
});
it('uses the compact logo-only empty state for a new design conversation', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
@@ -324,6 +498,62 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(emptyState).not.toHaveTextContent('告诉设计 Agent');
});
it('prefills the exact Prompt Museum text without sending it automatically', async () => {
const prompt = 'Create a quiet editorial poster with generous negative space.';
useImagePromptMuseumStore.getState().setPendingPrompt({
promptId: 'prompt-museum-one',
title: '编辑感产品海报',
prompt,
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const composer = await screen.findByLabelText('设计需求');
await waitFor(() => expect(composer).toHaveValue(prompt));
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(useImagePromptMuseumStore.getState().pendingPrompt).toBeNull();
});
it('keeps historical choices fixed after a later assistant turn', async () => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
quickReplies: ['暖色调', '冷色调'],
generationQuote: null,
};
conversation.messages.push(
{
id: 'message-follow-up-user',
role: 'user',
kind: 'user',
text: '继续调整色彩',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
},
{
id: 'message-follow-up-assistant',
role: 'assistant',
kind: 'choice',
text: '请选择下一步方向。',
quickReplies: ['增加对比度', '保持当前方案'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:01Z',
},
);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '暖色调' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '冷色调' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '增加对比度' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '保持当前方案' })).toBeInTheDocument();
});
it.each(['consumed', 'expired', 'superseded'] as const)(
'hides quote quick replies when the quote is %s',
async (status) => {
@@ -364,20 +594,50 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('opens a generated image at full size and saves it through Main', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '放大查看生成图片' }));
const taskCard = await screen.findByTestId('design-task-task-one');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const dialog = screen.getByRole('dialog', { name: '图片预览' });
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
expect(dialog).toHaveTextContent('1024 × 1024');
expect(screen.getByRole('img', { name: 'AI 设计大图预览' }))
expect(within(dialog).getByRole('img', { name: 'AI 设计大图预览' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host');
fireEvent.click(screen.getByRole('button', { name: '下载原图' }));
fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(
taskFixture.resultAssets[0],
));
});
it('shows video tasks as compact cards and exposes video controls in details', async () => {
const videoAsset = {
...taskFixture.resultAssets[0],
assetId: 'video-asset-one',
mediaType: 'video' as const,
mimeType: 'video/mp4',
durationMilliseconds: 4_000,
};
const videoTask = {
...taskFixture,
taskId: 'task-video-one',
medium: 'video' as const,
resultAssets: [videoAsset],
};
fetchImageWorkspaceTasksMock.mockResolvedValueOnce([videoTask]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-video-one');
expect(taskCard).toHaveTextContent('视频');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看视频任务详情' }));
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
expect(within(dialog).getByLabelText('AI 设计视频预览')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '下载视频' }));
await waitFor(() => expect(saveImageWorkspaceAssetMock).toHaveBeenCalledWith(videoAsset));
});
it('announces the image download progress while Main is saving', async () => {
let finishSaving: ((value: { status: 'saved' }) => void) | undefined;
saveImageWorkspaceAssetMock.mockReturnValue(new Promise((resolve) => {
@@ -385,15 +645,18 @@ describe('ImageCanvas Workspace-first design experience', () => {
}));
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '下载生成图片' }));
const taskCard = await screen.findByTestId('design-task-task-one');
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const dialog = await screen.findByRole('dialog', { name: '生成内容详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '下载原图' }));
const savingButton = screen.getByRole('button', { name: '正在保存生成图片' });
const savingButton = within(dialog).getByRole('button', { name: '正在下载生图' });
expect(savingButton).toBeDisabled();
expect(savingButton).toHaveAttribute('aria-busy', 'true');
finishSaving?.({ status: 'saved' });
await waitFor(() => expect(
screen.getByRole('button', { name: '下载生成图片' }),
within(dialog).getByRole('button', { name: '下载原图' }),
).toBeEnabled());
});
@@ -620,6 +883,52 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
});
it('reconciles a running Agent turn after returning to the conversation page', async () => {
const response = deferred<DesignConversation>();
const canonical: DesignConversation = {
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
messages: [{
id: 'message-latest',
role: 'assistant',
kind: 'reply',
text: '已完成最新方向整理。',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
}],
};
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
const firstRender = render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
act(() => taskEventSource.onopen?.(new Event('open')));
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '继续优化海报' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(await screen.findByText('继续优化海报')).toBeInTheDocument();
firstRender.unmount();
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(canonical);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByText('继续优化海报')).toBeInTheDocument();
expect(await screen.findByText('已完成最新方向整理。')).toBeInTheDocument();
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
expect(taskEventSource.close).not.toHaveBeenCalled();
await act(async () => {
response.resolve(canonical);
await response.promise;
});
});
it('does not expose confirmation quick replies when no active Quote exists', async () => {
const workspaceWithoutQuote = conversationFixture();
workspaceWithoutQuote.messages[1] = {
@@ -676,11 +985,53 @@ describe('ImageCanvas Workspace-first design experience', () => {
1,
'quote-one',
expect.stringMatching(/^turn-/),
'夜色中的机械城堡角色海报,电影感构图。',
{
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
});
it('keeps the final confirmation prompt visible in the conversation', async () => {
const confirmedConversation: DesignConversation = {
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
messages: [{
id: 'message-confirmed',
role: 'assistant',
kind: 'reply',
text: '设计任务已确认并开始执行。',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-07-31T10:01:00Z',
}],
};
confirmImageWorkspaceGenerationMock.mockResolvedValueOnce(confirmedConversation);
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
.mockResolvedValueOnce([{
...taskFixture,
taskId: 'task-confirmed-visible',
quoteId: 'quote-one',
status: 'queued',
resultAssets: [],
}]);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('design-quote-quote-one');
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
expect(await screen.findByText('确认生成')).toBeInTheDocument();
expect(screen.getByText('设计任务已确认并开始执行。')).toBeInTheDocument();
});
it('converts an exact composer confirmation into the structured Quote action', async () => {
fetchImageWorkspaceTasksMock
.mockResolvedValueOnce([taskFixture])
@@ -706,6 +1057,13 @@ describe('ImageCanvas Workspace-first design experience', () => {
1,
'quote-one',
expect.stringMatching(/^turn-/),
'夜色中的机械城堡角色海报,电影感构图。',
{
model: 'image-model-one',
resolution: '1024x1024',
aspectRatio: '1:1',
durationSeconds: null,
},
));
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(await screen.findByTestId('design-task-task-confirmed-from-composer'))
@@ -1012,6 +1370,9 @@ describe('ImageCanvas Workspace-first design experience', () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const taskCard = await screen.findByTestId('design-task-task-one');
expect(within(taskCard).queryByRole('alert')).not.toBeInTheDocument();
fireEvent.click(within(taskCard).getByRole('button', { name: '查看生图任务详情' }));
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('内容可能涉及版权或安全风险,请调整设计后重试');
expect(alert).not.toHaveTextContent('policy_blocked');