feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -3,246 +3,217 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
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 { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type { ImageWorkspaceSnapshot } from '../../shared/image-workspace';
|
||||
import type {
|
||||
DesignGenerationTask,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
} from '../../shared/image-workspace';
|
||||
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const addImageWorkspaceAgentMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const uploadImageWorkspaceReferenceMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
const resolveImageWorkspaceAssetUrlMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
||||
addImageWorkspaceAgent: (...args: unknown[]) => addImageWorkspaceAgentMock(...args),
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
uploadImageWorkspaceReference: (...args: unknown[]) => uploadImageWorkspaceReferenceMock(...args),
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
confirmImageWorkspaceGenerationMock(...args)
|
||||
),
|
||||
resolveImageWorkspaceAssetUrl: (...args: unknown[]) => (
|
||||
resolveImageWorkspaceAssetUrlMock(...args)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function workspaceFixture(): ImageWorkspaceSnapshot {
|
||||
return {
|
||||
activeProjectId: 'project-cloud',
|
||||
capabilities: {
|
||||
modes: [{ id: 'generate', label: '图片生成' }],
|
||||
models: [{ id: 'cloud-model', label: '云端模型 A' }],
|
||||
aspectRatios: [
|
||||
{ id: 'square', label: '1:1' },
|
||||
{ id: 'wide', label: '16:9' },
|
||||
],
|
||||
resolutions: [{ id: '2k', label: '2K' }],
|
||||
outputCounts: [{ id: 'four', label: '4 张' }],
|
||||
defaultModeId: 'generate',
|
||||
defaultModelId: 'cloud-model',
|
||||
defaultAspectRatioId: 'square',
|
||||
defaultResolutionId: '2k',
|
||||
defaultOutputCountId: 'four',
|
||||
maxReferenceImages: 3,
|
||||
referenceUpload: {
|
||||
enabled: true,
|
||||
acceptedMimeTypes: ['image/png', 'image/jpeg'],
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
},
|
||||
const bootstrapFixture: DesignWorkspaceBootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
workspaces: [{
|
||||
workspaceId: 'workspace-cloud',
|
||||
title: '云端角色设定',
|
||||
turnRevision: 1,
|
||||
viewRevision: 1,
|
||||
phase: 'awaiting_confirmation',
|
||||
brief: {
|
||||
version: 1,
|
||||
status: 'ready',
|
||||
medium: 'image',
|
||||
summary: '夜色中的机械城堡角色海报',
|
||||
ready: true,
|
||||
missingDecision: null,
|
||||
},
|
||||
projects: [{
|
||||
id: 'project-cloud',
|
||||
name: '云端角色设定',
|
||||
activeAgentId: 'agent-manual',
|
||||
updatedAt: '2026-07-28T10:00:00Z',
|
||||
agents: [{ id: 'agent-manual', name: '视觉创作 Agent' }],
|
||||
messages: [{
|
||||
id: 'message-result',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
}],
|
||||
};
|
||||
|
||||
function workspaceFixture(): DesignWorkspace {
|
||||
return {
|
||||
...bootstrapFixture.workspaces[0],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-user',
|
||||
role: 'user',
|
||||
kind: 'user',
|
||||
text: '做一张夜色中的机械城堡角色海报',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 1,
|
||||
createdAt: '2026-07-31T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'message-assistant',
|
||||
role: 'assistant',
|
||||
agentId: 'agent-manual',
|
||||
text: '已经为你生成一组角色图。',
|
||||
status: 'succeeded',
|
||||
createdAt: '2026-07-28T10:00:00Z',
|
||||
images: [{
|
||||
id: 'image-cloud-1',
|
||||
url: 'https://example.com/cloud-result.png',
|
||||
thumbnailUrl: 'https://example.com/cloud-result-thumb.png',
|
||||
alt: '云端角色结果',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
kind: 'confirmation',
|
||||
text: '方向已经明确,确认后开始生成。',
|
||||
quickReplies: ['确认生成', '继续调整'],
|
||||
generationQuote: {
|
||||
quoteId: 'quote-one',
|
||||
status: 'active',
|
||||
medium: 'image',
|
||||
briefVersion: 1,
|
||||
briefSummary: '夜色中的机械城堡角色海报',
|
||||
quotedDesignPoints: 1,
|
||||
expiresAt: '2026-07-31T11:00:00Z',
|
||||
},
|
||||
turnRevision: 1,
|
||||
createdAt: '2026-07-31T10:00:01Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resetAuthStore() {
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: '',
|
||||
clientId: 'app',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: null,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
user: {
|
||||
username: 'creator',
|
||||
userId: 'user_1',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: ['ROLE_USER'],
|
||||
},
|
||||
});
|
||||
}
|
||||
const taskFixture: DesignGenerationTask = {
|
||||
taskId: 'task-one',
|
||||
workspaceId: 'workspace-cloud',
|
||||
medium: 'image',
|
||||
status: 'succeeded',
|
||||
briefVersion: 1,
|
||||
briefSummary: '夜色中的机械城堡角色海报',
|
||||
quoteId: 'quote-old',
|
||||
quotedDesignPoints: 1,
|
||||
failureCode: null,
|
||||
resultAssets: [{
|
||||
assetId: 'asset-one',
|
||||
workspaceId: 'workspace-cloud',
|
||||
mediaType: 'image',
|
||||
mimeType: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
durationMilliseconds: null,
|
||||
createdAt: '2026-07-31T09:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-cloud/assets/asset-one/content',
|
||||
}],
|
||||
createdAt: '2026-07-31T09:00:00Z',
|
||||
updatedAt: '2026-07-31T09:01:00Z',
|
||||
};
|
||||
|
||||
describe('ImageCanvas cloud conversation workspace', () => {
|
||||
describe('ImageCanvas Workspace-first design experience', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.electron.imageWorkspaceLocalDevelopment = false;
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
resetAuthStore();
|
||||
const workspace = workspaceFixture();
|
||||
fetchImageWorkspaceMock.mockResolvedValue(workspace);
|
||||
addImageWorkspaceAgentMock.mockResolvedValue(workspace);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspace);
|
||||
uploadImageWorkspaceReferenceMock.mockResolvedValue({
|
||||
workspace,
|
||||
reference: {
|
||||
id: 'uploaded-reference',
|
||||
url: 'https://example.com/uploaded.png',
|
||||
alt: '上传的参考图',
|
||||
},
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
...workspaceFixture(),
|
||||
turnRevision: 2,
|
||||
phase: 'shaping',
|
||||
});
|
||||
resolveImageWorkspaceAssetUrlMock.mockResolvedValue(
|
||||
'http://127.0.0.1:13210/content?token=host',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an honest unavailable state instead of local or fabricated projects', async () => {
|
||||
it('shows an honest unavailable state without fabricating projects', async () => {
|
||||
fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
||||
501,
|
||||
'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
'创作空间暂不可用',
|
||||
'AI 设计暂不可用',
|
||||
));
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('image-workspace-unavailable')).toHaveTextContent('创作空间暂不可用');
|
||||
expect(screen.getByText(/暂时无法连接创作空间/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('制作中心')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('任务模块')).not.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('image-workspace-unavailable'))
|
||||
.toHaveTextContent('AI 设计暂不可用');
|
||||
expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cloud projects, conversations, agents, and server-provided generation controls', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('image-workspace-page')).toHaveTextContent('云端角色设定');
|
||||
expect(screen.getByTestId('image-workspace-header')).toHaveTextContent('Canvas');
|
||||
expect(screen.getByTestId('image-workspace-composer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('image-workspace-generation-controls')).toBeInTheDocument();
|
||||
expect(screen.getByText('当前 Agent:视觉创作 Agent')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('image-workspace-conversation')).toHaveTextContent('已经为你生成一组角色图');
|
||||
expect(screen.getByRole('img', { name: '云端角色结果' })).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/cloud-result-thumb.png',
|
||||
);
|
||||
expect(screen.getByLabelText('创作模式')).toHaveValue('generate');
|
||||
expect(screen.getByLabelText('模型')).toHaveValue('cloud-model');
|
||||
expect(screen.getByLabelText('画幅')).toHaveValue('square');
|
||||
expect(screen.getByLabelText('分辨率')).toHaveValue('2k');
|
||||
expect(screen.getByLabelText('生成数量')).toHaveValue('four');
|
||||
expect(screen.queryByText('Image2')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('云端角色设定')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('方向已经明确');
|
||||
expect(screen.getByText('设计 Agent')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
|
||||
expect(screen.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示');
|
||||
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
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('renders the anonymous development adapter with the same production copy', async () => {
|
||||
useAuthStore.setState({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
window.electron.imageWorkspaceLocalDevelopment = true;
|
||||
it('explains the project model before the first Workspace is created', async () => {
|
||||
fetchImageWorkspaceMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
activeProjectId: null,
|
||||
projects: [],
|
||||
...bootstrapFixture,
|
||||
workspaces: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByText('创建第一个项目')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新建项目' })).toBeInTheDocument();
|
||||
expect(fetchImageWorkspaceMock).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByText('本地开发')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('创建第一个本地项目')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('创建第一个云端项目')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
|
||||
expect(screen.getByText(/持续保留与设计 Agent 的对话/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds an output only after an explicit action and clears references after a successful turn', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
|
||||
await screen.findByTestId('image-workspace-page');
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
expect(screen.getByTestId('image-workspace-selected-references')).toHaveTextContent('云端角色结果');
|
||||
fireEvent.change(screen.getByLabelText('设计需求'), {
|
||||
target: { value: '把主角换成暖色轮廓光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('创作描述'), { target: { value: '把角色换成夜景光线' } });
|
||||
fireEvent.change(screen.getByLabelText('画幅'), { target: { value: 'wide' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith('access-token', {
|
||||
projectId: 'project-cloud',
|
||||
agentId: 'agent-manual',
|
||||
prompt: '把角色换成夜景光线',
|
||||
referenceImageIds: ['image-cloud-1'],
|
||||
settings: {
|
||||
modeId: 'generate',
|
||||
modelId: 'cloud-model',
|
||||
aspectRatioId: 'wide',
|
||||
resolutionId: '2k',
|
||||
outputCountId: 'four',
|
||||
},
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByTestId('image-workspace-selected-references')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('创作描述'), { target: { value: '另起一张纯文字创作' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenLastCalledWith(
|
||||
'access-token',
|
||||
expect.objectContaining({
|
||||
prompt: '另起一张纯文字创作',
|
||||
referenceImageIds: [],
|
||||
}),
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
1,
|
||||
'把主角换成暖色轮廓光',
|
||||
));
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads references through the cloud boundary and selects the returned cloud image', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/image-canvas']}>
|
||||
<ImageCanvas />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it('creates a generation task only through explicit Quote confirmation', async () => {
|
||||
fetchImageWorkspaceTasksMock
|
||||
.mockResolvedValueOnce([taskFixture])
|
||||
.mockResolvedValueOnce([{
|
||||
...taskFixture,
|
||||
taskId: 'task-two',
|
||||
status: 'queued',
|
||||
resultAssets: [],
|
||||
}]);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('design-quote-quote-one');
|
||||
|
||||
await screen.findByTestId('image-workspace-page');
|
||||
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('选择参考图'), { target: { files: [file] } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
|
||||
|
||||
await waitFor(() => expect(uploadImageWorkspaceReferenceMock).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
expect.objectContaining({
|
||||
projectId: 'project-cloud',
|
||||
fileName: 'reference.png',
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
));
|
||||
expect(await screen.findByTestId('image-workspace-selected-references')).toHaveTextContent('上传的参考图');
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
|
||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user