合并 AI 设计图生图客户端

This commit is contained in:
2026-08-13 15:41:45 +08:00
12 changed files with 301 additions and 69 deletions

View File

@@ -711,11 +711,152 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
});
it('keeps the historical video picker behavior when the brief medium is not decided yet', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: null,
summary: '',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-undecided-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' }))
.not.toBeInTheDocument();
});
it('opens the image reference picker and submits a successful work Asset id', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'image',
summary: '参考海洋公益海报生成新图',
ready: false,
missingDecision: '请选择参考图来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
expect(within(picker).getByText(/作为图生图参考/)).toBeInTheDocument();
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择图生图参考图' }))
.not.toBeInTheDocument();
});
it('uploads a local image reference and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择图生图参考图' }),
).not.toBeInTheDocument());
});
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
const pendingSelection = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
turnRevision: 2,
messages: [
...conversationFixture().messages,
@@ -759,6 +900,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
messages: [
...conversationFixture().messages,
{
@@ -793,6 +938,9 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择视频首帧' }),
).not.toBeInTheDocument());
});
it('renders policy-blocked generation tasks with a user-facing design message', async () => {

View File

@@ -232,6 +232,30 @@ describe('Works Square AI design adapter', () => {
);
});
it('normalizes a missing Brief medium to null and rejects invalid non-null values', async () => {
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
const legacyConversation = {
...serverConversation,
brief: legacyBrief,
};
const invalidConversation = {
...serverConversation,
brief: { ...serverConversation.brief, medium: 'audio' },
};
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(legacyConversation))
.mockResolvedValueOnce(jsonResponse(invalidConversation));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
});
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
.resolves.toMatchObject({ brief: { medium: null } });
await expect(adapter.getConversation('workspace-one', 'conversation-one'))
.rejects.toMatchObject({ code: 'DESIGN_WORKSPACE_RESPONSE_INVALID' });
});
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse(serverConversation))
@@ -940,6 +964,11 @@ describe('Works Square AI design adapter', () => {
});
it('reuses one design Agent Session and normalizes matching task events from fresh WebSocket tickets', async () => {
const { medium: _medium, ...legacyBrief } = serverConversation.brief;
const legacyConversation = {
...serverConversation,
brief: legacyBrief,
};
const snapshotTask = {
task_id: 'task-snapshot',
workspace_id: 'workspace-one',
@@ -964,10 +993,21 @@ describe('Works Square AI design adapter', () => {
workspace_id: 'workspace-one',
conversation_id: 'conversation-one',
workspace_view_revision: 2,
conversation: serverConversation,
conversation: legacyConversation,
generation_tasks: [snapshotTask],
},
};
const invalidSnapshotEvent = {
...snapshotEvent,
sequence: 98,
payload: {
...snapshotEvent.payload,
conversation: {
...serverConversation,
brief: { ...serverConversation.brief, medium: 'audio' },
},
},
};
const assistantDeltaEvent = {
session_id: 'session-one',
sequence: 2,
@@ -1038,6 +1078,7 @@ describe('Works Square AI design adapter', () => {
const { sockets, webSocketFactory } = scriptedSockets([
{
frames: [
{ type: 'event', event: invalidSnapshotEvent },
{ type: 'event', event: snapshotEvent },
{ type: 'event', event: malformedDeltaEvent },
{ type: 'event', event: assistantDeltaEvent },
@@ -1077,6 +1118,7 @@ describe('Works Square AI design adapter', () => {
conversation: expect.objectContaining({
conversationId: 'conversation-one',
title: serverConversation.title,
brief: expect.objectContaining({ medium: null }),
messages: [expect.objectContaining({
text: serverConversation.messages[0].text,
})],