feat(canvas): cut over design agent to living form v2
This commit is contained in:
@@ -7,20 +7,17 @@ import {
|
||||
hostApiFetch,
|
||||
} from '@/lib/host-api';
|
||||
import {
|
||||
confirmImageWorkspaceGeneration,
|
||||
createImageWorkspaceConversation,
|
||||
createImageWorkspaceProject,
|
||||
deleteImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceConversation,
|
||||
ImageWorkspaceApiError,
|
||||
openImageWorkspaceTaskEvents,
|
||||
openImageWorkspaceEvents,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
saveImageWorkspaceAsset,
|
||||
sendImageWorkspaceMessage,
|
||||
updateImageWorkspaceGenerationQuote,
|
||||
submitImageWorkspaceCommand,
|
||||
uploadImageWorkspaceAsset,
|
||||
} from '@/lib/image-workspace';
|
||||
import { designBootstrapFixture, designWorkspaceFixture } from '../fixtures/design-workspace-v2';
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn(),
|
||||
@@ -34,308 +31,170 @@ const createHostEventSourceMock = vi.mocked(createHostEventSource);
|
||||
const ensureHostApiTokenMock = vi.mocked(ensureHostApiToken);
|
||||
const getHostApiBaseMock = vi.mocked(getHostApiBase);
|
||||
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: false,
|
||||
},
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
describe('AI design renderer API boundary', () => {
|
||||
describe('AI design V2 Renderer API boundary', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset();
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, status: 200, data: bootstrap });
|
||||
ensureHostApiTokenMock.mockReset();
|
||||
vi.clearAllMocks();
|
||||
hostApiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: designBootstrapFixture(),
|
||||
});
|
||||
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
||||
createHostEventSourceMock.mockReset();
|
||||
createHostEventSourceMock.mockReturnValue({ close: vi.fn() } as unknown as EventSource);
|
||||
getHostApiBaseMock.mockReset();
|
||||
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
||||
});
|
||||
|
||||
it('uses only the Main-owned route and never forwards a Works Square token', async () => {
|
||||
it('uses only the Main-owned Host route', async () => {
|
||||
await fetchImageWorkspace();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
||||
});
|
||||
|
||||
it('opens task events only after the local Host API token is ready', async () => {
|
||||
const source = await openImageWorkspaceTaskEvents('workspace/one', 'conversation/one');
|
||||
it('creates and deletes one canonical Workspace without Conversation routes', async () => {
|
||||
hostApiFetchMock
|
||||
.mockResolvedValueOnce({ success: true, status: 200, data: designWorkspaceFixture() })
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: { workspaceId: 'workspace/one', deleted: true },
|
||||
});
|
||||
|
||||
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
||||
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events',
|
||||
);
|
||||
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('creates an idempotent Workspace with a trimmed user-visible title', async () => {
|
||||
await createImageWorkspaceProject(' 角色设计 ', 'workspace-client-1');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/workspaces', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientWorkspaceId: 'workspace-client-1',
|
||||
title: '角色设计',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a Workspace through its encoded Main-owned route', async () => {
|
||||
const result = { workspaceId: 'workspace/one', deleted: true as const };
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: result });
|
||||
|
||||
await expect(deleteImageWorkspaceProject('workspace/one')).resolves.toEqual(result);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
});
|
||||
|
||||
it('creates and reads Conversations through encoded Main-owned routes', async () => {
|
||||
await createImageWorkspaceConversation(
|
||||
'workspace/one',
|
||||
' 第二会话 ',
|
||||
'client-conversation-one',
|
||||
);
|
||||
await fetchImageWorkspaceConversation('workspace/one', 'conversation/one');
|
||||
await fetchImageWorkspaceConversation(
|
||||
'workspace/one',
|
||||
'conversation/one',
|
||||
'older page/+',
|
||||
);
|
||||
await createImageWorkspaceProject(' 角色设计 ', 'client-workspace-1');
|
||||
await deleteImageWorkspaceProject('workspace/one');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations',
|
||||
'/api/works/image-workspace/workspaces',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientConversationId: 'client-conversation-one',
|
||||
title: '第二会话',
|
||||
clientWorkspaceId: 'client-workspace-1',
|
||||
title: '角色设计',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone',
|
||||
{},
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone?before=older%20page%2F%2B',
|
||||
{},
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
});
|
||||
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace/one',
|
||||
'conversation/one',
|
||||
4,
|
||||
' 继续调整构图 ',
|
||||
'turn-client-1',
|
||||
);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/messages');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 4,
|
||||
message: '继续调整构图',
|
||||
attachmentAssetIds: [],
|
||||
clientTurnId: 'turn-client-1',
|
||||
});
|
||||
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
||||
});
|
||||
|
||||
it('sends the selected first-frame Asset id with the conversation turn', async () => {
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace-one',
|
||||
'conversation-one',
|
||||
5,
|
||||
'使用作品图片作为视频首帧',
|
||||
'turn-first-frame',
|
||||
['asset-first-frame'],
|
||||
);
|
||||
|
||||
const [, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
clientTurnId: 'turn-first-frame',
|
||||
expectedTurnRevision: 5,
|
||||
attachmentAssetIds: ['asset-first-frame'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a supported local image through the authenticated Main boundary', async () => {
|
||||
const asset = {
|
||||
assetId: 'asset-uploaded',
|
||||
workspaceId: 'workspace-one',
|
||||
mediaType: 'image' as const,
|
||||
mimeType: 'image/png',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
durationMilliseconds: null,
|
||||
createdAt: '2026-08-06T10:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace-one/assets/asset-uploaded/content',
|
||||
};
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: asset });
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'poster.webp', {
|
||||
type: 'image/webp',
|
||||
});
|
||||
|
||||
await expect(uploadImageWorkspaceAsset('workspace-one', file)).resolves.toEqual(asset);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace-one/assets');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
fileName: 'poster.webp',
|
||||
mimeType: 'image/webp',
|
||||
dataBase64: 'AQID',
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms a Quote through a separate structured action route', async () => {
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 'conversation-one', 5, 'quote/one');
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe(
|
||||
'/api/works/image-workspace/workspaces/workspace-one/conversations/conversation-one/quotes/quote%2Fone/confirm',
|
||||
);
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 5,
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
});
|
||||
});
|
||||
|
||||
it('re-quotes a Quote through the Main-owned PATCH route without adding defaults', async () => {
|
||||
const quote = {
|
||||
quoteId: 'quote-one',
|
||||
status: 'active',
|
||||
medium: 'video',
|
||||
briefVersion: 3,
|
||||
briefSummary: '校园海报转为动态视觉',
|
||||
finalPrompt: '用户最新编辑后的完整提示词',
|
||||
promptMode: 'guided',
|
||||
generationParameters: {
|
||||
model: 'wan2.7-i2v-2026-04-25',
|
||||
resolution: '720P',
|
||||
aspectRatio: '16:9',
|
||||
durationSeconds: 6,
|
||||
},
|
||||
generationOptions: { models: [], resolutions: [], durations: [], aspectRatios: [] },
|
||||
pricing: { schema: 'design-pricing-v2', amount: 9, rounding: 'ceil' },
|
||||
quotedDesignPoints: 9,
|
||||
expiresAt: '2026-08-15T10:30:00Z',
|
||||
} as const;
|
||||
hostApiFetchMock.mockResolvedValueOnce({ success: true, status: 200, data: quote });
|
||||
|
||||
await expect(updateImageWorkspaceGenerationQuote(
|
||||
'workspace-one',
|
||||
'quote-one',
|
||||
quote.finalPrompt,
|
||||
quote.generationParameters,
|
||||
)).resolves.toEqual(quote);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe(
|
||||
'/api/works/image-workspace/workspaces/workspace-one/generation-quotes/quote-one',
|
||||
);
|
||||
expect(init).toMatchObject({ method: 'PATCH' });
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
finalPrompt: quote.finalPrompt,
|
||||
model: quote.generationParameters.model,
|
||||
resolution: quote.generationParameters.resolution,
|
||||
aspectRatio: quote.generationParameters.aspectRatio,
|
||||
durationSeconds: quote.generationParameters.durationSeconds,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured Main error codes for revision recovery', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new AppError(
|
||||
'UNKNOWN',
|
||||
'Design workspace revision has changed',
|
||||
undefined,
|
||||
{ status: 409, backendCode: 'workspace_revision_conflict' },
|
||||
));
|
||||
|
||||
await expect(fetchImageWorkspace()).rejects.toMatchObject<ImageWorkspaceApiError>({
|
||||
status: 409,
|
||||
code: 'workspace_revision_conflict',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a loopback media URL with only the Host API session token', async () => {
|
||||
await expect(resolveImageWorkspaceAssetUrl('/api/works/image-workspace/assets/one'))
|
||||
.resolves.toBe(
|
||||
'http://127.0.0.1:13210/api/works/image-workspace/assets/one?token=host-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('asks Main to save a private image asset without exposing its cloud URL', async () => {
|
||||
it('submits a discriminated V2 command without duplicating Workspace identity in the body', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: { status: 'saved' },
|
||||
data: {
|
||||
clientOperationId: 'operation-1',
|
||||
runId: 'run-1',
|
||||
workspace: designWorkspaceFixture(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(saveImageWorkspaceAsset({
|
||||
await submitImageWorkspaceCommand({
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace/one',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-1',
|
||||
input: {
|
||||
kind: 'direct_edit',
|
||||
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/commands',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
kind: 'apply_input',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-1',
|
||||
input: {
|
||||
kind: 'direct_edit',
|
||||
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('opens resumable Workspace events after the Host token is ready', async () => {
|
||||
const source = await openImageWorkspaceEvents('workspace/one', 'session/one', 'session/one:8');
|
||||
|
||||
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
||||
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/events?sessionId=session%2Fone&afterEventId=session%2Fone%3A8',
|
||||
);
|
||||
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('uploads only supported bounded images through Main', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: { assetId: 'asset-1' },
|
||||
});
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'reference.png', { type: 'image/png' });
|
||||
|
||||
await uploadImageWorkspaceAsset('workspace-1', file);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace-1/assets',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: 'reference.png',
|
||||
mimeType: 'image/png',
|
||||
dataBase64: 'AQID',
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves and saves assets without exposing the Works Square token', async () => {
|
||||
const asset = {
|
||||
assetId: 'asset/one',
|
||||
workspaceId: 'workspace/one',
|
||||
mediaType: 'image',
|
||||
role: 'generated' as const,
|
||||
mediaType: 'image' as const,
|
||||
mimeType: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
durationMilliseconds: null,
|
||||
createdAt: '2026-08-05T12:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/private/content',
|
||||
})).resolves.toEqual({ status: 'saved' });
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
defaultFileName: 'Makelore-AI-Design-asset-one.png',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(JSON.stringify(hostApiFetchMock.mock.calls[0])).not.toContain('private/content');
|
||||
});
|
||||
|
||||
it('uses a video extension when Main saves a generated video asset', async () => {
|
||||
generationTaskId: 'task-1',
|
||||
createdAt: '2026-08-30T10:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/content',
|
||||
};
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: { status: 'saved' },
|
||||
});
|
||||
|
||||
await saveImageWorkspaceAsset({
|
||||
assetId: 'video/one',
|
||||
workspaceId: 'workspace/one',
|
||||
mediaType: 'video',
|
||||
mimeType: 'video/mp4',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationMilliseconds: 4_000,
|
||||
createdAt: '2026-08-05T12:00:00Z',
|
||||
contentPath: '/api/works/image-workspace/private/video',
|
||||
});
|
||||
await expect(resolveImageWorkspaceAssetUrl(asset.contentPath)).resolves.toBe(
|
||||
'http://127.0.0.1:13210/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/content?token=host-token',
|
||||
);
|
||||
await saveImageWorkspaceAsset(asset);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/video%2Fone/download',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
defaultFileName: 'Makelore-AI-Design-video-one.mp4',
|
||||
}),
|
||||
},
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes Host failures into a stable API error', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, {
|
||||
status: 409,
|
||||
backendCode: 'design_direction_revision_conflict',
|
||||
}));
|
||||
|
||||
await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({
|
||||
name: 'ImageWorkspaceApiError',
|
||||
status: 409,
|
||||
code: 'design_direction_revision_conflict',
|
||||
} satisfies Partial<ImageWorkspaceApiError>));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user