feat: 流式展示设计 Agent 对话

需求:设计 Agent 对话与确认生成的回复需要实时展示。

实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
2026-08-02 21:21:50 +08:00
parent 518d7ab4cd
commit 90a249db31
9 changed files with 819 additions and 93 deletions

View File

@@ -6,6 +6,7 @@ import { ImageCanvas } from '@/pages/ImageCanvas';
import { useAuthStore } from '@/stores/auth';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAssistantDeltaEvent,
DesignGenerationTask,
DesignGenerationTaskUpdatedEvent,
DesignWorkspace,
@@ -40,6 +41,14 @@ class MockEventSource {
}
}
function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
return {
@@ -309,10 +318,57 @@ describe('ImageCanvas Workspace-first design experience', () => {
'workspace-cloud',
1,
'把主角换成暖色轮廓光',
expect.stringMatching(/^turn-/),
));
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
});
it('renders the pending user turn and assistant deltas while the command is running', async () => {
const response = deferred<DesignWorkspace>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '让画面更有海洋呼吸感' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(await screen.findByText('让画面更有海洋呼吸感')).toBeInTheDocument();
expect(screen.getByRole('status', { name: '设计 Agent 正在回复' })).toBeInTheDocument();
const pending = useImageWorkspaceStore.getState().pendingTurn!;
act(() => {
taskEventSource.emit('design.assistant.delta', {
id: 'session-one:2',
type: 'design.assistant.delta',
workspaceId: 'workspace-cloud',
clientTurnId: pending.clientTurnId,
turnRevision: pending.turnRevision,
chunkIndex: 0,
delta: '可以先增加',
} satisfies DesignAssistantDeltaEvent);
taskEventSource.emit('design.assistant.delta', {
id: 'session-one:3',
type: 'design.assistant.delta',
workspaceId: 'workspace-cloud',
clientTurnId: pending.clientTurnId,
turnRevision: pending.turnRevision,
chunkIndex: 1,
delta: '留白和水流节奏。',
} satisfies DesignAssistantDeltaEvent);
});
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('可以先增加留白和水流节奏。');
act(() => response.resolve({
...workspaceFixture(),
turnRevision: 2,
viewRevision: 2,
}));
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
});
it('refreshes tasks after an Agent turn creates a generation task', async () => {
const queuedTask = {
...taskFixture,
@@ -337,6 +393,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
'workspace-cloud',
1,
confirmationReply,
expect.stringMatching(/^turn-/),
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
@@ -357,7 +414,12 @@ describe('ImageCanvas Workspace-first design experience', () => {
fireEvent.click(screen.getByRole('button', { name: '确认并开始生成' }));
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith('workspace-cloud', 1, 'quote-one'));
.toHaveBeenCalledWith(
'workspace-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
));
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
});

View File

@@ -80,7 +80,12 @@ describe('AI design renderer API boundary', () => {
});
it('sends conversation turns with the current turn revision and no provider settings', async () => {
await sendImageWorkspaceMessage('workspace/one', 4, ' 继续调整构图 ');
await sendImageWorkspaceMessage(
'workspace/one',
4,
' 继续调整构图 ',
'turn-client-1',
);
const [path, init] = hostApiFetchMock.mock.calls[0];
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
@@ -89,7 +94,7 @@ describe('AI design renderer API boundary', () => {
expectedTurnRevision: 4,
message: '继续调整构图',
attachmentAssetIds: [],
clientTurnId: expect.stringMatching(/^turn-/),
clientTurnId: 'turn-client-1',
});
expect(String(init?.body)).not.toMatch(/model|resolution|outputCount/);
});

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAssistantDeltaEvent,
DesignGenerationTask,
DesignGenerationTasksSnapshotEvent,
DesignGenerationTaskUpdatedEvent,
@@ -12,6 +13,7 @@ const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
@@ -21,6 +23,7 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
};
});
@@ -123,6 +126,7 @@ function taskSnapshotEvent(
type: 'design.generation_tasks.snapshot',
workspaceId: 'workspace-one',
workspaceViewRevision,
workspace: workspace('workspace-one', workspaceViewRevision),
generationTasks: [{
...task,
status,
@@ -139,6 +143,7 @@ describe('AI design task event store', () => {
fetchImageWorkspaceMock.mockResolvedValue(bootstrap());
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
sendImageWorkspaceMessageMock.mockResolvedValue(workspace());
});
afterEach(() => {
@@ -167,6 +172,70 @@ describe('AI design task event store', () => {
});
});
it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => {
const source = new MockEventSource();
const response = deferred<DesignWorkspace>();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
await useImageWorkspaceStore.getState().load();
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
const sending = useImageWorkspaceStore.getState().sendMessage('把主视觉改成鲸鱼');
const pending = useImageWorkspaceStore.getState().pendingTurn;
expect(pending).toMatchObject({
userText: '把主视觉改成鲸鱼',
assistantText: '',
turnRevision: 2,
});
const firstDelta = {
id: 'session-one:2',
type: 'design.assistant.delta',
workspaceId: 'workspace-one',
clientTurnId: pending!.clientTurnId,
turnRevision: 2,
chunkIndex: 0,
delta: '可以,先强化',
} satisfies DesignAssistantDeltaEvent;
source.emit('design.assistant.delta', firstDelta);
source.emit('design.assistant.delta', firstDelta);
source.emit('design.assistant.delta', {
...firstDelta,
id: 'session-one:3',
chunkIndex: 1,
delta: '鲸鱼的轮廓。',
} satisfies DesignAssistantDeltaEvent);
expect(useImageWorkspaceStore.getState().pendingTurn?.assistantText)
.toBe('可以,先强化鲸鱼的轮廓。');
const canonical = {
...workspace('workspace-one', 2),
turnRevision: 2,
messages: [{
id: 'workspace-one:2:assistant:0',
role: 'assistant' as const,
kind: 'reply' as const,
text: '可以,先强化鲸鱼的轮廓。',
quickReplies: [],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-02T10:01:00Z',
}],
};
source.emit('design.generation_tasks.snapshot', {
...taskSnapshotEvent(2, 'queued'),
id: 'session-one:4',
workspace: canonical,
} satisfies DesignGenerationTasksSnapshotEvent);
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
expect(useImageWorkspaceStore.getState().workspace?.messages[0].text)
.toBe('可以,先强化鲸鱼的轮廓。');
response.resolve(canonical);
await sending;
});
it('closes the previous stream when switching workspaces and on reset', async () => {
const first = new MockEventSource();
const second = new MockEventSource();
@@ -189,6 +258,35 @@ describe('AI design task event store', () => {
expect(useImageWorkspaceStore.getState().taskStreamState).toBe('idle');
});
it('does not switch back when an Agent turn finishes after selecting another Workspace', async () => {
const first = new MockEventSource();
const second = new MockEventSource();
const response = deferred<DesignWorkspace>();
openImageWorkspaceTaskEventsMock
.mockResolvedValueOnce(first as unknown as EventSource)
.mockResolvedValueOnce(second as unknown as EventSource);
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
Promise.resolve(workspace(workspaceId))
));
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
await useImageWorkspaceStore.getState().load();
const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报');
await useImageWorkspaceStore.getState().selectProject('workspace-two');
response.resolve({
...workspace('workspace-one', 2),
turnRevision: 2,
});
await sending;
expect(useImageWorkspaceStore.getState()).toMatchObject({
activeWorkspaceId: 'workspace-two',
workspace: { workspaceId: 'workspace-two' },
pendingTurn: null,
});
});
it('does not let a slow previous Workspace selection overwrite the latest one', async () => {
const rootSource = new MockEventSource();
const latestSource = new MockEventSource();

View File

@@ -147,8 +147,123 @@ describe('Works Square AI design adapter', () => {
))).toBe(true);
});
it('submits a conversation turn through the persistent Agent Gateway Session', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-one',
status: 'queued',
error: null,
}, 202))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-one',
status: 'succeeded',
error: null,
}))
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
clientInstanceId: 'installation-one',
});
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
clientTurnId: 'turn-two',
expectedTurnRevision: 1,
message: '做一张保护海洋的公益海报',
attachmentAssetIds: ['asset-reference'],
})).resolves.toMatchObject({
workspaceId: 'workspace-one',
turnRevision: 1,
});
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://square.example/api/agents/sessions/session-one/commands',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
client_command_id: 'turn-two',
name: 'turn.submit',
input: {
expected_turn_revision: 1,
message: '做一张保护海洋的公益海报',
attachment_asset_ids: ['asset-reference'],
action: null,
},
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'https://square.example/api/agents/sessions/session-one/runs/run-one',
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'https://square.example/api/design/workspaces/workspace-one',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('maps an invalid Runtime command to a user input error', async () => {
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-invalid',
status: 'queued',
error: null,
}, 202))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-invalid',
status: 'failed',
error: {
code: 'agent_command_invalid',
message: 'private validation detail',
retryable: false,
},
}));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example',
fetchImpl: fetchMock,
});
await expect(adapter.submitMessage({
workspaceId: 'workspace-one',
clientTurnId: 'turn-invalid',
expectedTurnRevision: 1,
message: 'invalid',
})).rejects.toMatchObject({
status: 422,
code: 'agent_command_invalid',
message: '设计请求内容无效,请检查后重试',
});
});
it('keeps task creation behind structured Quote confirmation', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(serverWorkspace));
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({
session_id: 'session-one',
status: 'active',
}, 201))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-confirm',
status: 'queued',
error: null,
}, 202))
.mockResolvedValueOnce(jsonResponse({
run_id: 'run-confirm',
status: 'succeeded',
error: null,
}))
.mockResolvedValueOnce(jsonResponse(serverWorkspace));
const adapter = new WorksSquareDesignWorkspace({
apiBaseUrl: 'https://square.example/',
fetchImpl: fetchMock,
@@ -162,19 +277,33 @@ describe('Works Square AI design adapter', () => {
});
expect(workspace.workspaceId).toBe('workspace-one');
expect(fetchMock).toHaveBeenCalledWith(
'https://square.example/api/design/workspaces/workspace-one/turns',
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://square.example/api/agents/sessions/session-one/commands',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
client_turn_id: 'turn-two',
expected_turn_revision: 1,
message: '确认生成',
attachment_asset_ids: [],
action: { type: 'confirm_generation', quote_id: 'quote-one' },
client_command_id: 'turn-two',
name: 'turn.submit',
input: {
expected_turn_revision: 1,
message: '确认生成',
attachment_asset_ids: [],
action: { type: 'confirm_generation', quote_id: 'quote-one' },
},
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'https://square.example/api/agents/sessions/session-one/runs/run-confirm',
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'https://square.example/api/design/workspaces/workspace-one',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('maps stable generation tasks and private asset relay paths', async () => {
@@ -278,13 +407,32 @@ describe('Works Square AI design adapter', () => {
type: 'design.workspace.updated',
schema_version: 1,
payload: {
workspace: {
workspace_id: 'workspace-one',
view_revision: 2,
},
workspace: serverWorkspace,
generation_tasks: [snapshotTask],
},
};
const assistantDeltaEvent = {
session_id: 'session-one',
sequence: 2,
runtime: 'design',
type: 'design.assistant.delta',
schema_version: 1,
payload: {
workspace_id: 'workspace-one',
client_turn_id: 'turn-two',
turn_revision: 2,
chunk_index: 0,
delta: '方向已经明确',
},
};
const malformedDeltaEvent = {
...assistantDeltaEvent,
sequence: 99,
payload: {
...assistantDeltaEvent.payload,
chunk_index: -1,
},
};
const taskEvent = {
session_id: 'session-one',
sequence: 3,
@@ -336,6 +484,8 @@ describe('Works Square AI design adapter', () => {
{
frames: [
{ type: 'event', event: snapshotEvent },
{ type: 'event', event: malformedDeltaEvent },
{ type: 'event', event: assistantDeltaEvent },
{ type: 'event', event: taskEvent },
],
closeCode: 1000,
@@ -367,12 +517,28 @@ describe('Works Square AI design adapter', () => {
type: 'design.generation_tasks.snapshot',
workspaceId: 'workspace-one',
workspaceViewRevision: 2,
workspace: expect.objectContaining({
workspaceId: 'workspace-one',
title: serverWorkspace.title,
messages: [expect.objectContaining({
text: serverWorkspace.messages[0].text,
})],
}),
generationTasks: [expect.objectContaining({
taskId: 'task-snapshot',
medium: 'image',
status: 'running',
})],
},
{
id: 'session-one:2',
type: 'design.assistant.delta',
workspaceId: 'workspace-one',
clientTurnId: 'turn-two',
turnRevision: 2,
chunkIndex: 0,
delta: '方向已经明确',
},
{
id: 'session-one:3',
type: 'design.generation_task.updated',