feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -19,9 +19,10 @@ function createTemporaryDirectory(): string {
|
||||
|
||||
function createService(userDataDir: string) {
|
||||
let sequence = 0;
|
||||
let tick = 0;
|
||||
return new LocalImageWorkspace({
|
||||
userDataDir,
|
||||
now: () => new Date('2026-07-29T10:00:00.000Z'),
|
||||
now: () => new Date(Date.parse('2026-07-31T10:00:00.000Z') + tick++ * 1_000),
|
||||
createId: () => String(++sequence),
|
||||
});
|
||||
}
|
||||
@@ -32,137 +33,127 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('local image workspace', () => {
|
||||
it('routes the default development command through the local workspace adapter', () => {
|
||||
describe('local AI design workspace', () => {
|
||||
it('routes the default development command through the local adapter', () => {
|
||||
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.scripts.dev).toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
expect(packageJson.scripts['dev:image-workspace:local']).toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
expect(packageJson.scripts['dev:image-workspace:local'])
|
||||
.toBe('node scripts/run-local-image-workspace-dev.mjs');
|
||||
});
|
||||
|
||||
it('enables local mode for an unpackaged development server and never for packaged apps', () => {
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, configuredMode: 'local' })).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, isDevelopmentServer: true })).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, isDevelopmentServer: false })).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: false, configuredMode: 'cloud', isDevelopmentServer: true })).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: true, configuredMode: 'local' })).toBe(false);
|
||||
it('never enables the local adapter in packaged apps or explicit cloud mode', () => {
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
configuredMode: 'local',
|
||||
})).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
isDevelopmentServer: true,
|
||||
})).toBe(true);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: false,
|
||||
configuredMode: 'cloud',
|
||||
isDevelopmentServer: true,
|
||||
})).toBe(false);
|
||||
expect(isLocalImageWorkspaceDevelopmentEnabled({
|
||||
isPackaged: true,
|
||||
configuredMode: 'local',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('starts without Agents, persists manually added Agents, and survives a new service instance', async () => {
|
||||
it('persists titled Workspaces and keeps creation idempotent by client key', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
|
||||
const emptyWorkspace = await service.getSnapshot();
|
||||
expect(emptyWorkspace).toMatchObject({
|
||||
projects: [],
|
||||
activeProjectId: null,
|
||||
await expect(service.bootstrap()).resolves.toMatchObject({ workspaces: [] });
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: ' 本地角色设计 ',
|
||||
});
|
||||
expect(emptyWorkspace).not.toHaveProperty('runtimeMode');
|
||||
|
||||
const created = await service.createProject(' 本地角色设计 ');
|
||||
expect(created.projects).toHaveLength(1);
|
||||
expect(created.projects[0]).toMatchObject({
|
||||
name: '本地角色设计',
|
||||
agents: [],
|
||||
messages: [],
|
||||
const replay = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-one',
|
||||
title: '不能覆盖标题',
|
||||
});
|
||||
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const withSecondAgent = await service.addAgent(withAgent.projects[0].id);
|
||||
expect(withSecondAgent.projects[0].agents.map((agent) => agent.name)).toEqual([
|
||||
'创作 Agent 1',
|
||||
'创作 Agent 2',
|
||||
]);
|
||||
expect(replay.workspaceId).toBe(created.workspaceId);
|
||||
expect(replay.title).toBe('本地角色设计');
|
||||
await service.renameWorkspace({
|
||||
workspaceId: created.workspaceId,
|
||||
title: '角色设计第二版',
|
||||
});
|
||||
|
||||
const reloaded = new LocalImageWorkspace({ userDataDir });
|
||||
await expect(reloaded.getSnapshot()).resolves.toMatchObject({
|
||||
activeProjectId: created.projects[0].id,
|
||||
projects: [{ name: '本地角色设计', agents: [{}, {}] }],
|
||||
await expect(reloaded.bootstrap()).resolves.toMatchObject({
|
||||
workspaces: [{ title: '角色设计第二版', turnRevision: 0, viewRevision: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('persists reference uploads and creates deterministic multi-image placeholder turns', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
const created = await service.createProject('连续创作');
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const project = withAgent.projects[0];
|
||||
const agent = project.agents[0];
|
||||
const upload = await service.uploadReference({
|
||||
projectId: project.id,
|
||||
fileName: '参考图.png',
|
||||
mimeType: 'image/png',
|
||||
contentBase64: Buffer.from('fake-png-bytes').toString('base64'),
|
||||
it('creates a Quote first and a visible generation task only after confirmation', async () => {
|
||||
const service = createService(createTemporaryDirectory());
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-flow',
|
||||
title: '海洋公益海报',
|
||||
});
|
||||
|
||||
expect(upload.reference).toMatchObject({
|
||||
alt: '参考图.png',
|
||||
url: expect.stringMatching(/^data:image\/png;base64,/),
|
||||
const discussed = await service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 0,
|
||||
message: '做一张保护海洋的竖版公益海报',
|
||||
});
|
||||
const quote = discussed.messages.at(-1)?.generationQuote;
|
||||
|
||||
const input = {
|
||||
projectId: project.id,
|
||||
agentId: agent.id,
|
||||
prompt: '夜色中的机械城堡',
|
||||
referenceImageIds: [upload.reference.id],
|
||||
settings: {
|
||||
modeId: 'edit',
|
||||
modelId: 'local-placeholder-v1',
|
||||
aspectRatioId: '16:9',
|
||||
resolutionId: '1024',
|
||||
outputCountId: '2',
|
||||
},
|
||||
};
|
||||
const firstTurn = await service.sendMessage(input);
|
||||
const firstOutput = firstTurn.projects[0].messages.at(-1)!;
|
||||
expect(discussed.phase).toBe('awaiting_confirmation');
|
||||
expect(quote).toMatchObject({ status: 'active', medium: 'image' });
|
||||
await expect(service.listTasks(created.workspaceId)).resolves.toEqual([]);
|
||||
|
||||
expect(firstTurn.projects[0].messages).toHaveLength(2);
|
||||
expect(firstTurn.projects[0].messages[0].images).toEqual([upload.reference]);
|
||||
expect(firstOutput).toMatchObject({
|
||||
role: 'assistant',
|
||||
const confirmed = await service.confirmGeneration({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 1,
|
||||
quoteId: quote!.quoteId,
|
||||
});
|
||||
expect(confirmed.turnRevision).toBe(2);
|
||||
|
||||
const queued = await service.listTasks(created.workspaceId);
|
||||
const running = await service.listTasks(created.workspaceId);
|
||||
const succeeded = await service.listTasks(created.workspaceId);
|
||||
expect(queued[0].status).toBe('queued');
|
||||
expect(running[0].status).toBe('running');
|
||||
expect(succeeded[0]).toMatchObject({
|
||||
status: 'succeeded',
|
||||
text: '已生成 2 张图片。',
|
||||
resultAssets: [{ mimeType: 'image/svg+xml' }],
|
||||
});
|
||||
expect(firstOutput.images).toHaveLength(2);
|
||||
expect(firstOutput.images[0].url).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
const placeholderSvg = Buffer.from(firstOutput.images[0].url.split(',')[1], 'base64').toString('utf8');
|
||||
expect(placeholderSvg).toContain('CREATIVE · 1');
|
||||
expect(placeholderSvg).toContain('标准创作模型');
|
||||
expect(placeholderSvg).not.toMatch(/LOCAL DEV|local-placeholder-v1|本地开发|本地占位/);
|
||||
|
||||
const secondTurn = await service.sendMessage(input);
|
||||
const secondOutput = secondTurn.projects[0].messages.at(-1)!;
|
||||
expect(secondOutput.images.map((image) => image.url)).toEqual(
|
||||
firstOutput.images.map((image) => image.url),
|
||||
);
|
||||
|
||||
const reloaded = new LocalImageWorkspace({ userDataDir });
|
||||
expect((await reloaded.getSnapshot()).projects[0].messages).toHaveLength(4);
|
||||
const asset = succeeded[0].resultAssets[0];
|
||||
const response = await service.openAssetContent(created.workspaceId, asset.assetId);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('image/svg+xml');
|
||||
expect(await response.text()).toContain('AI Design Workspace');
|
||||
});
|
||||
|
||||
it('rejects unknown option ids and removes all persisted data on reset', async () => {
|
||||
it('enforces turn CAS and removes only the isolated development data on reset', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const service = createService(userDataDir);
|
||||
const created = await service.createProject('可重置项目');
|
||||
const withAgent = await service.addAgent(created.projects[0].id);
|
||||
const project = withAgent.projects[0];
|
||||
const created = await service.createWorkspace({
|
||||
clientWorkspaceId: 'client-reset',
|
||||
title: '可重置项目',
|
||||
});
|
||||
|
||||
await expect(service.sendMessage({
|
||||
projectId: project.id,
|
||||
agentId: project.agents[0].id,
|
||||
prompt: '测试',
|
||||
referenceImageIds: [],
|
||||
settings: { modelId: 'unknown-model' },
|
||||
await expect(service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
clientTurnId: 'stale-turn',
|
||||
expectedTurnRevision: 3,
|
||||
message: '测试',
|
||||
})).rejects.toMatchObject<LocalImageWorkspaceError>({
|
||||
status: 400,
|
||||
code: 'IMAGE_WORKSPACE_INVALID_SETTING',
|
||||
status: 409,
|
||||
code: 'workspace_revision_conflict',
|
||||
});
|
||||
|
||||
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true);
|
||||
await expect(service.reset()).resolves.toMatchObject({ projects: [], activeProjectId: null });
|
||||
await expect(service.reset()).resolves.toMatchObject({ workspaces: [] });
|
||||
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(false);
|
||||
await expect(new LocalImageWorkspace({ userDataDir }).getSnapshot()).resolves.toMatchObject({ projects: [] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user