feat: 流式展示设计 Agent 对话
需求:设计 Agent 对话与确认生成的回复需要实时展示。 实现:统一通过 Agent Gateway 提交 Turn,接收并去重 assistant delta,以 canonical Workspace 收口,并修复跨项目旧请求回写竞态。
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user