feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -1,110 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
ensureHostApiToken,
|
||||
getHostApiBase,
|
||||
hostApiFetch,
|
||||
} from '@/lib/host-api';
|
||||
import {
|
||||
confirmImageWorkspaceGeneration,
|
||||
createImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
ImageWorkspaceApiError,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
sendImageWorkspaceMessage,
|
||||
uploadImageWorkspaceReference,
|
||||
} from '@/lib/image-workspace';
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({ hostApiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: vi.fn(),
|
||||
ensureHostApiToken: vi.fn(),
|
||||
getHostApiBase: vi.fn(),
|
||||
}));
|
||||
|
||||
const hostApiFetchMock = vi.mocked(hostApiFetch);
|
||||
const ensureHostApiTokenMock = vi.mocked(ensureHostApiToken);
|
||||
const getHostApiBaseMock = vi.mocked(getHostApiBase);
|
||||
|
||||
const workspace = {
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: false,
|
||||
},
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
describe('image workspace renderer API boundary', () => {
|
||||
describe('AI design renderer API boundary', () => {
|
||||
beforeEach(() => {
|
||||
hostApiFetchMock.mockReset();
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, workspace });
|
||||
hostApiFetchMock.mockResolvedValue({ success: true, status: 200, data: bootstrap });
|
||||
ensureHostApiTokenMock.mockReset();
|
||||
ensureHostApiTokenMock.mockResolvedValue('host-token');
|
||||
getHostApiBaseMock.mockReset();
|
||||
getHostApiBaseMock.mockReturnValue('http://127.0.0.1:13210');
|
||||
});
|
||||
|
||||
it('uses a dedicated Main-owned route and forwards the cloud access token', async () => {
|
||||
await fetchImageWorkspace('cloud-token');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows an adapter-backed initial load without an access token', async () => {
|
||||
await fetchImageWorkspace(null);
|
||||
it('uses only the Main-owned route and never forwards a Works Square token', async () => {
|
||||
await fetchImageWorkspace();
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace', {});
|
||||
});
|
||||
|
||||
it('creates a cloud project with only its trimmed name', async () => {
|
||||
await createImageWorkspaceProject('cloud-token', ' 角色设计 ');
|
||||
it('creates an idempotent Workspace with a trimmed user-visible title', async () => {
|
||||
await createImageWorkspaceProject(' 角色设计 ', 'workspace-client-1');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/projects', {
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
body: JSON.stringify({ name: '角色设计' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('sends explicit references and server option ids without local task semantics', async () => {
|
||||
await sendImageWorkspaceMessage('cloud-token', {
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['image-one'],
|
||||
settings: { modelId: 'server-model' },
|
||||
});
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/image-workspace/projects/project%2Fone/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'X-NianCode-Access-Token': 'cloud-token' },
|
||||
body: JSON.stringify({
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['image-one'],
|
||||
settings: { modelId: 'server-model' },
|
||||
clientWorkspaceId: 'workspace-client-1',
|
||||
title: '角色设计',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the upload response to return a cloud reference id', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
workspace,
|
||||
reference: { id: 'reference-one', url: 'https://example.com/reference.png' },
|
||||
});
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
await sendImageWorkspaceMessage('workspace/one', 4, ' 继续调整构图 ');
|
||||
|
||||
await expect(uploadImageWorkspaceReference('cloud-token', {
|
||||
projectId: 'project-one',
|
||||
fileName: 'reference.png',
|
||||
mimeType: 'image/png',
|
||||
contentBase64: 'aW1hZ2U=',
|
||||
})).resolves.toMatchObject({ reference: { id: 'reference-one' } });
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 4,
|
||||
message: '继续调整构图',
|
||||
attachmentAssetIds: [],
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
});
|
||||
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
|
||||
});
|
||||
|
||||
it('maps the unimplemented Main route to a stable unavailable error', async () => {
|
||||
it('confirms a Quote through a separate structured action route', async () => {
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one');
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe(
|
||||
'/api/works/image-workspace/workspaces/workspace-one/quotes/quote%2Fone/confirm',
|
||||
);
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 5,
|
||||
clientTurnId: expect.stringMatching(/^turn-/),
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves structured Main error codes for revision recovery', async () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new AppError(
|
||||
'UNKNOWN',
|
||||
'创作空间暂不可用',
|
||||
'Design workspace revision has changed',
|
||||
undefined,
|
||||
{ status: 501 },
|
||||
{ status: 409, backendCode: 'workspace_revision_conflict' },
|
||||
));
|
||||
|
||||
await expect(fetchImageWorkspace('cloud-token')).rejects.toMatchObject<ImageWorkspaceApiError>({
|
||||
status: 501,
|
||||
code: 'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
message: '创作空间暂不可用',
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user