feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
|
||||
import type { HostApiContext } from '@electron/api/context';
|
||||
import type { ImageWorkspaceSnapshot } from '../../shared/image-workspace';
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headersSent: false,
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((chunk?: string) => {
|
||||
if (chunk) chunks.push(chunk);
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
@@ -24,27 +29,45 @@ function createResponse() {
|
||||
}
|
||||
|
||||
function createRequest(method: string, body?: unknown): IncomingMessage {
|
||||
const request = Readable.from(body === undefined ? [] : [JSON.stringify(body)]) as unknown as IncomingMessage;
|
||||
const request = Readable.from(
|
||||
body === undefined ? [] : [JSON.stringify(body)],
|
||||
) as unknown as IncomingMessage;
|
||||
request.method = method;
|
||||
request.headers = body === undefined ? {} : { 'content-type': 'application/json' };
|
||||
return request;
|
||||
}
|
||||
|
||||
const localSnapshot: ImageWorkspaceSnapshot = {
|
||||
class MediaResponse extends Writable {
|
||||
statusCode = 0;
|
||||
readonly chunks: Buffer[] = [];
|
||||
readonly headers = new Map<string, string>();
|
||||
|
||||
setHeader(name: string, value: string | number | readonly string[]): this {
|
||||
this.headers.set(name.toLowerCase(), String(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
_write(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
callback: (error?: Error | null) => void,
|
||||
): void {
|
||||
this.chunks.push(Buffer.from(chunk));
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
modes: [],
|
||||
models: [],
|
||||
aspectRatios: [],
|
||||
resolutions: [],
|
||||
outputCounts: [],
|
||||
maxReferenceImages: 0,
|
||||
referenceUpload: { enabled: false, acceptedMimeTypes: [] },
|
||||
conversation: true,
|
||||
generation: true,
|
||||
image: true,
|
||||
video: true,
|
||||
},
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
describe('image workspace Main route boundary', () => {
|
||||
describe('AI design Main route boundary', () => {
|
||||
it('does not claim unrelated routes', async () => {
|
||||
const response = createResponse();
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
@@ -58,38 +81,37 @@ describe('image workspace Main route boundary', () => {
|
||||
expect(response.res.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a stable unavailable response without fabricating cloud state', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
it('returns a stable unavailable response only when Main has no module', async () => {
|
||||
const response = createResponse();
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
await handleImageWorkspaceRoutes(
|
||||
{ method: 'GET' } as IncomingMessage,
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(501);
|
||||
expect(response.json()).toEqual({
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
status: 501,
|
||||
code: 'IMAGE_WORKSPACE_UNAVAILABLE',
|
||||
error: '创作空间暂不可用',
|
||||
error: 'AI 设计暂不可用',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves the local workspace only when Main injects the development service', async () => {
|
||||
const imageWorkspace = {
|
||||
getSnapshot: vi.fn().mockResolvedValue(localSnapshot),
|
||||
createProject: vi.fn().mockResolvedValue(localSnapshot),
|
||||
addAgent: vi.fn().mockResolvedValue(localSnapshot),
|
||||
sendMessage: vi.fn().mockResolvedValue(localSnapshot),
|
||||
uploadReference: vi.fn(),
|
||||
reset: vi.fn().mockResolvedValue(localSnapshot),
|
||||
it('maps Host routes to the deep Workspace module instead of provider-shaped calls', async () => {
|
||||
const workspace = {
|
||||
bootstrap: vi.fn().mockResolvedValue(bootstrap),
|
||||
createWorkspace: vi.fn().mockResolvedValue({ workspaceId: 'workspace-one' }),
|
||||
renameWorkspace: vi.fn(),
|
||||
getWorkspace: vi.fn(),
|
||||
submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
||||
confirmGeneration: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
openAssetContent: vi.fn(),
|
||||
getCapabilities: vi.fn(),
|
||||
reset: vi.fn().mockResolvedValue(bootstrap),
|
||||
};
|
||||
const ctx = { imageWorkspace } as unknown as HostApiContext;
|
||||
const ctx = { imageWorkspace: workspace } as unknown as HostApiContext;
|
||||
|
||||
const getResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
@@ -98,47 +120,99 @@ describe('image workspace Main route boundary', () => {
|
||||
new URL('http://127.0.0.1/api/works/image-workspace'),
|
||||
ctx,
|
||||
);
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json()).toMatchObject({ success: true, workspace: localSnapshot });
|
||||
expect(getResponse.json()).toMatchObject({ success: true, data: bootstrap });
|
||||
|
||||
const createResponseBody = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', { name: ' 测试项目 ' }),
|
||||
createRequest('POST', {
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '角色设计',
|
||||
}),
|
||||
createResponseBody.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/projects'),
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/workspaces'),
|
||||
ctx,
|
||||
);
|
||||
expect(imageWorkspace.createProject).toHaveBeenCalledWith(' 测试项目 ');
|
||||
expect(workspace.createWorkspace).toHaveBeenCalledWith({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '角色设计',
|
||||
});
|
||||
|
||||
const messageResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
projectId: 'spoofed-project',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['reference-one'],
|
||||
settings: { outputCountId: '2' },
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 3,
|
||||
message: '继续编辑',
|
||||
attachmentAssetIds: [],
|
||||
modelId: 'must-be-ignored',
|
||||
}),
|
||||
messageResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/projects/project%2Fone/messages'),
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/messages',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(imageWorkspace.sendMessage).toHaveBeenCalledWith({
|
||||
projectId: 'project/one',
|
||||
agentId: 'agent-one',
|
||||
prompt: '继续编辑',
|
||||
referenceImageIds: ['reference-one'],
|
||||
settings: { outputCountId: '2' },
|
||||
expect(workspace.submitMessage).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 3,
|
||||
message: '继续编辑',
|
||||
attachmentAssetIds: [],
|
||||
});
|
||||
|
||||
const resetResponse = createResponse();
|
||||
const confirmResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('DELETE'),
|
||||
resetResponse.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/local-data'),
|
||||
createRequest('POST', {
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 4,
|
||||
}),
|
||||
confirmResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/quotes/quote%2Fone/confirm',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(imageWorkspace.reset).toHaveBeenCalledOnce();
|
||||
expect(resetResponse.json()).toMatchObject({ workspace: { projects: [] } });
|
||||
expect(workspace.confirmGeneration).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
quoteId: 'quote/one',
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('streams private asset bytes and preserves Range response headers', async () => {
|
||||
const openAssetContent = vi.fn().mockResolvedValue(new Response('partial', {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': 'bytes 0-6/100',
|
||||
'Content-Type': 'video/mp4',
|
||||
},
|
||||
}));
|
||||
const ctx = {
|
||||
imageWorkspace: { openAssetContent },
|
||||
} as unknown as HostApiContext;
|
||||
const request = createRequest('GET');
|
||||
request.headers = { range: 'bytes=0-6' };
|
||||
const response = new MediaResponse();
|
||||
|
||||
const handled = await handleImageWorkspaceRoutes(
|
||||
request,
|
||||
response as unknown as ServerResponse,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/content',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(openAssetContent).toHaveBeenCalledWith(
|
||||
'workspace-one',
|
||||
'asset-one',
|
||||
'bytes=0-6',
|
||||
);
|
||||
expect(response.statusCode).toBe(206);
|
||||
expect(response.headers.get('content-range')).toBe('bytes 0-6/100');
|
||||
expect(Buffer.concat(response.chunks).toString()).toBe('partial');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user