合并 AI 设计多会话客户端
整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
This commit is contained in:
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignConversation,
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
@@ -15,6 +16,8 @@ import type {
|
||||
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const createImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -57,6 +60,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
fetchImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
createImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
createImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||
@@ -87,8 +96,19 @@ const bootstrapFixture: DesignWorkspaceBootstrap = {
|
||||
workspaces: [{
|
||||
workspaceId: 'workspace-cloud',
|
||||
title: '云端角色设定',
|
||||
turnRevision: 1,
|
||||
viewRevision: 1,
|
||||
conversationCount: 1,
|
||||
phase: 'awaiting_confirmation',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
}],
|
||||
};
|
||||
|
||||
function conversationFixture(): DesignConversation {
|
||||
return {
|
||||
conversationId: 'conversation-cloud',
|
||||
workspaceId: 'workspace-cloud',
|
||||
title: '主会话',
|
||||
turnRevision: 1,
|
||||
phase: 'awaiting_confirmation',
|
||||
brief: {
|
||||
version: 1,
|
||||
@@ -98,13 +118,8 @@ const bootstrapFixture: DesignWorkspaceBootstrap = {
|
||||
ready: true,
|
||||
missingDecision: null,
|
||||
},
|
||||
createdAt: '2026-07-31T10:00:00Z',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
}],
|
||||
};
|
||||
|
||||
function workspaceFixture(): DesignWorkspace {
|
||||
return {
|
||||
...bootstrapFixture.workspaces[0],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-user',
|
||||
@@ -138,9 +153,18 @@ function workspaceFixture(): DesignWorkspace {
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceFixture(): DesignWorkspace {
|
||||
const { messages: _messages, ...summary } = conversationFixture();
|
||||
return {
|
||||
...bootstrapFixture.workspaces[0],
|
||||
conversations: [summary],
|
||||
};
|
||||
}
|
||||
|
||||
const taskFixture: DesignGenerationTask = {
|
||||
taskId: 'task-one',
|
||||
workspaceId: 'workspace-cloud',
|
||||
conversationId: 'conversation-cloud',
|
||||
medium: 'image',
|
||||
status: 'succeeded',
|
||||
briefVersion: 1,
|
||||
@@ -171,10 +195,12 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
...workspaceFixture(),
|
||||
...conversationFixture(),
|
||||
turnRevision: 2,
|
||||
phase: 'shaping',
|
||||
});
|
||||
@@ -212,7 +238,8 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: 'invalid-refresh-token',
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'invalid-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1,
|
||||
user: {
|
||||
@@ -234,14 +261,16 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await screen.findByTestId('image-workspace-unavailable');
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: null,
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().status).toBe('auth-required');
|
||||
|
||||
useAuthStore.setState({
|
||||
accessToken: 'fresh-access-token',
|
||||
refreshToken: 'fresh-refresh-token',
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: 'fresh-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
user: {
|
||||
@@ -273,6 +302,77 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(screen.queryByText(/当前 Agent/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates and switches Conversations without hiding project generation tasks', async () => {
|
||||
const firstConversation = conversationFixture();
|
||||
const secondConversation: DesignConversation = {
|
||||
...conversationFixture(),
|
||||
conversationId: 'conversation-two',
|
||||
title: '第二会话',
|
||||
turnRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
status: 'draft',
|
||||
medium: null,
|
||||
summary: '',
|
||||
ready: false,
|
||||
missingDecision: '请描述新的设计方向',
|
||||
},
|
||||
messages: [{
|
||||
id: 'message-two',
|
||||
role: 'assistant',
|
||||
kind: 'reply',
|
||||
text: '这是独立的第二会话',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 0,
|
||||
createdAt: '2026-07-31T10:05:00Z',
|
||||
}],
|
||||
createdAt: '2026-07-31T10:05:00Z',
|
||||
updatedAt: '2026-07-31T10:05:00Z',
|
||||
};
|
||||
const { messages: _firstMessages, ...firstSummary } = firstConversation;
|
||||
const { messages: _secondMessages, ...secondSummary } = secondConversation;
|
||||
fetchImageWorkspaceProjectMock
|
||||
.mockResolvedValueOnce(workspaceFixture())
|
||||
.mockResolvedValue({
|
||||
...workspaceFixture(),
|
||||
conversationCount: 2,
|
||||
conversations: [secondSummary, firstSummary],
|
||||
});
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(secondConversation);
|
||||
fetchImageWorkspaceConversationMock.mockImplementation(
|
||||
async (_workspaceId: string, conversationId: string) => (
|
||||
conversationId === secondConversation.conversationId
|
||||
? secondConversation
|
||||
: firstConversation
|
||||
),
|
||||
);
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
expect(await screen.findByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
|
||||
|
||||
await waitFor(() => expect(createImageWorkspaceConversationMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud', '新会话'));
|
||||
await waitFor(() => expect(screen.getByLabelText('切换设计会话'))
|
||||
.toHaveValue('conversation-two'));
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('这是独立的第二会话');
|
||||
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('切换设计会话'), {
|
||||
target: { value: 'conversation-cloud' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fetchImageWorkspaceConversationMock)
|
||||
.toHaveBeenLastCalledWith('workspace-cloud', 'conversation-cloud'));
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('方向已经明确');
|
||||
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
|
||||
});
|
||||
|
||||
it('opens a generated image at full size and saves it through Main', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
@@ -365,6 +465,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
1,
|
||||
'把主角换成暖色轮廓光',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -372,8 +473,45 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the design message with Enter', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
const composer = screen.getByLabelText('设计需求');
|
||||
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '让画面增加一层晨雾' },
|
||||
});
|
||||
fireEvent.keyDown(composer, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
1,
|
||||
'让画面增加一层晨雾',
|
||||
expect.stringMatching(/^turn-/),
|
||||
));
|
||||
expect(composer).toHaveValue('');
|
||||
expect(screen.getByTestId('image-workspace-composer'))
|
||||
.toHaveTextContent('Enter 发送 · Shift+Enter 换行');
|
||||
});
|
||||
|
||||
it('keeps Shift+Enter for newlines and ignores Enter during IME composition', async () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
const composer = screen.getByLabelText('设计需求');
|
||||
|
||||
fireEvent.change(composer, {
|
||||
target: { value: '正在输入中文设计描述' },
|
||||
});
|
||||
fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true });
|
||||
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true });
|
||||
|
||||
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
|
||||
expect(composer).toHaveValue('正在输入中文设计描述');
|
||||
});
|
||||
|
||||
it('renders the pending user turn and assistant deltas while the command is running', async () => {
|
||||
const response = deferred<DesignWorkspace>();
|
||||
const response = deferred<DesignConversation>();
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByText('云端角色设定');
|
||||
@@ -392,6 +530,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
id: 'session-one:2',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-cloud',
|
||||
conversationId: 'conversation-cloud',
|
||||
clientTurnId: pending.clientTurnId,
|
||||
turnRevision: pending.turnRevision,
|
||||
chunkIndex: 0,
|
||||
@@ -409,6 +548,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
id: 'session-one:3',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-cloud',
|
||||
conversationId: 'conversation-cloud',
|
||||
clientTurnId: pending.clientTurnId,
|
||||
turnRevision: pending.turnRevision,
|
||||
chunkIndex: 1,
|
||||
@@ -419,15 +559,14 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(screen.getByTestId('image-workspace-conversation'))
|
||||
.toHaveTextContent('可以先增加留白和水流节奏。');
|
||||
act(() => response.resolve({
|
||||
...workspaceFixture(),
|
||||
...conversationFixture(),
|
||||
turnRevision: 2,
|
||||
viewRevision: 2,
|
||||
}));
|
||||
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
|
||||
});
|
||||
|
||||
it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
|
||||
const workspaceWithoutQuote = workspaceFixture();
|
||||
const workspaceWithoutQuote = conversationFixture();
|
||||
workspaceWithoutQuote.messages[1] = {
|
||||
...workspaceWithoutQuote.messages[1],
|
||||
generationQuote: null,
|
||||
@@ -441,7 +580,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
video: false,
|
||||
},
|
||||
});
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce(workspaceWithoutQuote);
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote);
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
const confirmReply = await screen.findByRole('button', { name: '确认生成' });
|
||||
@@ -474,6 +613,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
1,
|
||||
'quote-one',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -503,6 +643,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
|
||||
.toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
1,
|
||||
'quote-one',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -524,6 +665,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
1,
|
||||
question,
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -532,8 +674,8 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
});
|
||||
|
||||
it('opens the successful work picker when the Agent asks for a video first frame', async () => {
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
|
||||
...conversationFixture(),
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 2,
|
||||
@@ -544,7 +686,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
missingDecision: '请选择首帧来源',
|
||||
},
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
...conversationFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
@@ -556,7 +698,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
createdAt: '2026-08-06T09:00:00Z',
|
||||
},
|
||||
],
|
||||
} satisfies DesignWorkspace);
|
||||
} satisfies DesignConversation);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
@@ -570,13 +712,13 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
});
|
||||
|
||||
it('submits a successful work Asset id when it is selected as the video first frame', async () => {
|
||||
const pendingSelection = deferred<DesignWorkspace>();
|
||||
const pendingSelection = deferred<DesignConversation>();
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
|
||||
...conversationFixture(),
|
||||
turnRevision: 2,
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
...conversationFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
@@ -588,7 +730,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
createdAt: '2026-08-06T09:00:00Z',
|
||||
},
|
||||
],
|
||||
} satisfies DesignWorkspace);
|
||||
} satisfies DesignConversation);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
|
||||
@@ -600,6 +742,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
2,
|
||||
'使用作品图片作为视频首帧',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -608,16 +751,16 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
pendingSelection.resolve(workspaceFixture());
|
||||
pendingSelection.resolve(conversationFixture());
|
||||
await pendingSelection.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValueOnce({
|
||||
...workspaceFixture(),
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
|
||||
...conversationFixture(),
|
||||
messages: [
|
||||
...workspaceFixture().messages,
|
||||
...conversationFixture().messages,
|
||||
{
|
||||
id: 'message-video-source',
|
||||
role: 'assistant',
|
||||
@@ -630,7 +773,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
},
|
||||
],
|
||||
turnRevision: 2,
|
||||
} satisfies DesignWorkspace);
|
||||
} satisfies DesignConversation);
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
|
||||
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
|
||||
@@ -644,6 +787,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
.toHaveBeenCalledWith('workspace-cloud', file));
|
||||
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
|
||||
'workspace-cloud',
|
||||
'conversation-cloud',
|
||||
2,
|
||||
'使用上传的图片作为视频首帧',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -678,7 +822,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('design-quote-quote-one');
|
||||
await waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-cloud'));
|
||||
.toHaveBeenCalledWith('workspace-cloud', 'conversation-cloud'));
|
||||
await waitFor(() => expect(taskEventSource.onopen).not.toBeNull());
|
||||
act(() => {
|
||||
taskEventSource.onopen?.(new Event('open'));
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@/lib/host-api';
|
||||
import {
|
||||
confirmImageWorkspaceGeneration,
|
||||
createImageWorkspaceConversation,
|
||||
createImageWorkspaceProject,
|
||||
fetchImageWorkspace,
|
||||
fetchImageWorkspaceConversation,
|
||||
ImageWorkspaceApiError,
|
||||
openImageWorkspaceTaskEvents,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
@@ -59,11 +61,11 @@ describe('AI design renderer API boundary', () => {
|
||||
});
|
||||
|
||||
it('opens task events only after the local Host API token is ready', async () => {
|
||||
const source = await openImageWorkspaceTaskEvents('workspace/one');
|
||||
const source = await openImageWorkspaceTaskEvents('workspace/one', 'conversation/one');
|
||||
|
||||
expect(source).toBe(createHostEventSourceMock.mock.results[0].value);
|
||||
expect(createHostEventSourceMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events',
|
||||
);
|
||||
expect(ensureHostApiTokenMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(createHostEventSourceMock.mock.invocationCallOrder[0]);
|
||||
@@ -81,16 +83,43 @@ describe('AI design renderer API boundary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and reads Conversations through encoded Main-owned routes', async () => {
|
||||
await createImageWorkspaceConversation(
|
||||
'workspace/one',
|
||||
' 第二会话 ',
|
||||
'client-conversation-one',
|
||||
);
|
||||
await fetchImageWorkspaceConversation('workspace/one', 'conversation/one');
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientConversationId: 'client-conversation-one',
|
||||
title: '第二会话',
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(hostApiFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('sends conversation turns with the current turn revision and no provider settings', async () => {
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace/one',
|
||||
'conversation/one',
|
||||
4,
|
||||
' 继续调整构图 ',
|
||||
'turn-client-1',
|
||||
);
|
||||
|
||||
const [path, init] = hostApiFetchMock.mock.calls[0];
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/messages');
|
||||
expect(path).toBe('/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/messages');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 4,
|
||||
@@ -104,6 +133,7 @@ describe('AI design renderer API boundary', () => {
|
||||
it('sends the selected first-frame Asset id with the conversation turn', async () => {
|
||||
await sendImageWorkspaceMessage(
|
||||
'workspace-one',
|
||||
'conversation-one',
|
||||
5,
|
||||
'使用作品图片作为视频首帧',
|
||||
'turn-first-frame',
|
||||
@@ -148,11 +178,11 @@ describe('AI design renderer API boundary', () => {
|
||||
});
|
||||
|
||||
it('confirms a Quote through a separate structured action route', async () => {
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 5, 'quote/one');
|
||||
await confirmImageWorkspaceGeneration('workspace-one', 'conversation-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',
|
||||
'/api/works/image-workspace/workspaces/workspace-one/conversations/conversation-one/quotes/quote%2Fone/confirm',
|
||||
);
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
expectedTurnRevision: 5,
|
||||
|
||||
@@ -130,6 +130,8 @@ describe('AI design Main route boundary', () => {
|
||||
const workspace = {
|
||||
bootstrap: vi.fn().mockResolvedValue(bootstrap),
|
||||
createWorkspace: vi.fn().mockResolvedValue({ workspaceId: 'workspace-one' }),
|
||||
createConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation-one' }),
|
||||
getConversation: vi.fn().mockResolvedValue({ conversationId: 'conversation/one' }),
|
||||
renameWorkspace: vi.fn(),
|
||||
getWorkspace: vi.fn(),
|
||||
submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
|
||||
@@ -166,6 +168,43 @@ describe('AI design Main route boundary', () => {
|
||||
title: '角色设计',
|
||||
});
|
||||
|
||||
const createConversationResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
clientConversationId: 'client-conversation-one',
|
||||
title: '第二会话',
|
||||
}),
|
||||
createConversationResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.createConversation).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
clientConversationId: 'client-conversation-one',
|
||||
title: '第二会话',
|
||||
});
|
||||
expect(createConversationResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
data: { conversationId: 'conversation-one' },
|
||||
});
|
||||
|
||||
const getConversationResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('GET'),
|
||||
getConversationResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.getConversation).toHaveBeenCalledWith('workspace/one', 'conversation/one');
|
||||
expect(getConversationResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
data: { conversationId: 'conversation/one' },
|
||||
});
|
||||
|
||||
const messageResponse = createResponse();
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
@@ -177,12 +216,13 @@ describe('AI design Main route boundary', () => {
|
||||
}),
|
||||
messageResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/messages',
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/messages',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.submitMessage).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
conversationId: 'conversation/one',
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 3,
|
||||
message: '继续编辑',
|
||||
@@ -221,12 +261,13 @@ describe('AI design Main route boundary', () => {
|
||||
}),
|
||||
confirmResponse.res,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/quotes/quote%2Fone/confirm',
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/quotes/quote%2Fone/confirm',
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
expect(workspace.confirmGeneration).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
conversationId: 'conversation/one',
|
||||
quoteId: 'quote/one',
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 4,
|
||||
@@ -391,6 +432,7 @@ describe('AI design Main route boundary', () => {
|
||||
generationTask: {
|
||||
taskId: 'task-live',
|
||||
workspaceId: 'workspace/one',
|
||||
conversationId: 'conversation/one',
|
||||
medium: 'image' as const,
|
||||
status: 'succeeded' as const,
|
||||
briefVersion: 1,
|
||||
@@ -414,7 +456,7 @@ describe('AI design Main route boundary', () => {
|
||||
request,
|
||||
response as unknown as ServerResponse,
|
||||
new URL(
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/events',
|
||||
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/conversations/conversation%2Fone/events',
|
||||
),
|
||||
{ imageWorkspace: { openWorkspaceEvents } } as unknown as HostApiContext,
|
||||
);
|
||||
@@ -422,6 +464,7 @@ describe('AI design Main route boundary', () => {
|
||||
expect(handled).toBe(true);
|
||||
expect(openWorkspaceEvents).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace/one',
|
||||
conversationId: 'conversation/one',
|
||||
afterEventId: 'session-one:6',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignConversation,
|
||||
DesignConversationSnapshotEvent,
|
||||
DesignGenerationTask,
|
||||
DesignGenerationTasksSnapshotEvent,
|
||||
DesignGenerationTaskUpdatedEvent,
|
||||
DesignWorkspace,
|
||||
DesignWorkspaceBootstrap,
|
||||
@@ -11,6 +12,8 @@ import type {
|
||||
|
||||
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const createImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
@@ -22,6 +25,12 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
...actual,
|
||||
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
|
||||
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
|
||||
fetchImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
fetchImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
createImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
createImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
@@ -62,6 +71,7 @@ function deferred<T>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
const task: DesignGenerationTask = {
|
||||
taskId: 'task-one',
|
||||
workspaceId: 'workspace-one',
|
||||
conversationId: 'conversation-one',
|
||||
medium: 'image',
|
||||
status: 'queued',
|
||||
briefVersion: 1,
|
||||
@@ -74,12 +84,16 @@ const task: DesignGenerationTask = {
|
||||
updatedAt: '2026-08-02T10:00:00Z',
|
||||
};
|
||||
|
||||
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
|
||||
function conversation(
|
||||
workspaceId = 'workspace-one',
|
||||
turnRevision = 1,
|
||||
conversationId = workspaceId.replace('workspace', 'conversation'),
|
||||
): DesignConversation {
|
||||
return {
|
||||
conversationId,
|
||||
workspaceId,
|
||||
title: workspaceId,
|
||||
turnRevision: 1,
|
||||
viewRevision,
|
||||
title: '主会话',
|
||||
turnRevision,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 1,
|
||||
@@ -91,6 +105,20 @@ function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorks
|
||||
},
|
||||
updatedAt: '2026-08-02T10:00:00Z',
|
||||
messages: [],
|
||||
createdAt: '2026-08-02T10:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(workspaceId = 'workspace-one', viewRevision = 1): DesignWorkspace {
|
||||
const { messages: _messages, ...conversationSummary } = conversation(workspaceId);
|
||||
return {
|
||||
workspaceId,
|
||||
title: workspaceId,
|
||||
viewRevision,
|
||||
conversationCount: 1,
|
||||
phase: conversationSummary.phase,
|
||||
updatedAt: '2026-08-02T10:00:00Z',
|
||||
conversations: [conversationSummary],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +126,7 @@ function bootstrap(workspaceIds = ['workspace-one']): DesignWorkspaceBootstrap {
|
||||
return {
|
||||
capabilities: { conversation: true, generation: true, image: true, video: true },
|
||||
workspaces: workspaceIds.map((workspaceId) => {
|
||||
const { messages: _messages, ...summary } = workspace(workspaceId);
|
||||
const { conversations: _conversations, ...summary } = workspace(workspaceId);
|
||||
return summary;
|
||||
}),
|
||||
};
|
||||
@@ -124,13 +152,14 @@ function taskEvent(
|
||||
function taskSnapshotEvent(
|
||||
workspaceViewRevision: number,
|
||||
status: DesignGenerationTask['status'],
|
||||
): DesignGenerationTasksSnapshotEvent {
|
||||
): DesignConversationSnapshotEvent {
|
||||
return {
|
||||
id: `session-one:${workspaceViewRevision}`,
|
||||
type: 'design.generation_tasks.snapshot',
|
||||
type: 'design.conversation.snapshot',
|
||||
workspaceId: 'workspace-one',
|
||||
conversationId: 'conversation-one',
|
||||
workspaceViewRevision,
|
||||
workspace: workspace('workspace-one', workspaceViewRevision),
|
||||
conversation: conversation('workspace-one', workspaceViewRevision),
|
||||
generationTasks: [{
|
||||
...task,
|
||||
status,
|
||||
@@ -146,10 +175,16 @@ describe('AI design task event store', () => {
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap());
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
||||
fetchImageWorkspaceConversationMock.mockImplementation((workspaceId: string, conversationId: string) => (
|
||||
Promise.resolve(conversation(workspaceId, 1, conversationId))
|
||||
));
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(
|
||||
conversation('workspace-one', 0, 'conversation-new'),
|
||||
);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspace());
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(conversation());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
...workspace('workspace-one', 2),
|
||||
...conversation('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
});
|
||||
});
|
||||
@@ -159,30 +194,30 @@ describe('AI design task event store', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps a revision-5 snapshot over revision 3, then upserts a newer task by task id', async () => {
|
||||
it('uses task event revisions instead of the project view revision as the task watermark', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 5));
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace('workspace-one', 50));
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-one'));
|
||||
.toHaveBeenCalledWith('workspace-one', 'conversation-one'));
|
||||
source.onopen?.(new Event('open'));
|
||||
source.emit('design.generation_task.updated', taskEvent(3, 'failed'));
|
||||
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('queued');
|
||||
source.emit('design.generation_tasks.snapshot', taskSnapshotEvent(6, 'running'));
|
||||
expect(useImageWorkspaceStore.getState().tasks[0].status).toBe('failed');
|
||||
source.emit('design.conversation.snapshot', taskSnapshotEvent(6, 'running'));
|
||||
source.emit('design.generation_task.updated', taskEvent(5, 'failed'));
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
taskStreamState: 'connected',
|
||||
workspace: { viewRevision: 6 },
|
||||
workspace: { viewRevision: 50 },
|
||||
tasks: [{ taskId: 'task-one', status: 'running' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('assembles assistant deltas once and replaces them with the canonical snapshot', async () => {
|
||||
const source = new MockEventSource();
|
||||
const response = deferred<DesignWorkspace>();
|
||||
const response = deferred<DesignConversation>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
@@ -200,6 +235,7 @@ describe('AI design task event store', () => {
|
||||
id: 'session-one:2',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-one',
|
||||
conversationId: 'conversation-one',
|
||||
clientTurnId: pending!.clientTurnId,
|
||||
turnRevision: 2,
|
||||
chunkIndex: 0,
|
||||
@@ -218,7 +254,7 @@ describe('AI design task event store', () => {
|
||||
.toBe('可以,先强化鲸鱼的轮廓。');
|
||||
|
||||
const canonical = {
|
||||
...workspace('workspace-one', 2),
|
||||
...conversation('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
messages: [{
|
||||
id: 'workspace-one:2:assistant:0',
|
||||
@@ -231,14 +267,14 @@ describe('AI design task event store', () => {
|
||||
createdAt: '2026-08-02T10:01:00Z',
|
||||
}],
|
||||
};
|
||||
source.emit('design.generation_tasks.snapshot', {
|
||||
source.emit('design.conversation.snapshot', {
|
||||
...taskSnapshotEvent(2, 'queued'),
|
||||
id: 'session-one:4',
|
||||
workspace: canonical,
|
||||
} satisfies DesignGenerationTasksSnapshotEvent);
|
||||
conversation: canonical,
|
||||
} satisfies DesignConversationSnapshotEvent);
|
||||
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
||||
expect(useImageWorkspaceStore.getState().workspace?.messages[0].text)
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages[0].text)
|
||||
.toBe('可以,先强化鲸鱼的轮廓。');
|
||||
response.resolve(canonical);
|
||||
await sending;
|
||||
@@ -257,6 +293,7 @@ describe('AI design task event store', () => {
|
||||
|
||||
expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith(
|
||||
'workspace-one',
|
||||
'conversation-one',
|
||||
1,
|
||||
'quote-one',
|
||||
expect.stringMatching(/^turn-/),
|
||||
@@ -309,7 +346,7 @@ describe('AI design task event store', () => {
|
||||
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>();
|
||||
const response = deferred<DesignConversation>();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
@@ -323,7 +360,7 @@ describe('AI design task event store', () => {
|
||||
const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报');
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
||||
response.resolve({
|
||||
...workspace('workspace-one', 2),
|
||||
...conversation('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
});
|
||||
await sending;
|
||||
@@ -356,7 +393,7 @@ describe('AI design task event store', () => {
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenCalledWith('workspace-root'));
|
||||
.toHaveBeenCalledWith('workspace-root', 'conversation-root'));
|
||||
const staleSelection = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
||||
await vi.waitFor(() => expect(fetchImageWorkspaceProjectMock)
|
||||
.toHaveBeenCalledWith('workspace-one'));
|
||||
@@ -366,10 +403,11 @@ describe('AI design task event store', () => {
|
||||
|
||||
expect(useImageWorkspaceStore.getState().activeWorkspaceId).toBe('workspace-two');
|
||||
expect(useImageWorkspaceStore.getState().workspace?.workspaceId).toBe('workspace-two');
|
||||
expect(openImageWorkspaceTaskEventsMock).not.toHaveBeenCalledWith('workspace-one');
|
||||
expect(openImageWorkspaceTaskEventsMock)
|
||||
.not.toHaveBeenCalledWith('workspace-one', 'conversation-one');
|
||||
});
|
||||
|
||||
it('advances each task watermark after a degraded REST snapshot', async () => {
|
||||
it('uses updatedAt to keep a newer degraded REST task over an older replay', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
|
||||
@@ -379,6 +417,7 @@ describe('AI design task event store', () => {
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([{
|
||||
...task,
|
||||
status: 'succeeded',
|
||||
updatedAt: '2026-08-02T11:00:00Z',
|
||||
}]);
|
||||
await useImageWorkspaceStore.getState().refreshWorkspace();
|
||||
await useImageWorkspaceStore.getState().refreshTasks();
|
||||
@@ -404,4 +443,203 @@ describe('AI design task event store', () => {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('switches the conversation stream without clearing project generation tasks', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
const project = workspace();
|
||||
const { messages: _messages, ...secondSummary } = conversation(
|
||||
'workspace-one',
|
||||
0,
|
||||
'conversation-two',
|
||||
);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
||||
...project,
|
||||
conversationCount: 2,
|
||||
conversations: [...project.conversations, secondSummary],
|
||||
});
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
||||
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(openImageWorkspaceTaskEventsMock)
|
||||
.toHaveBeenLastCalledWith('workspace-one', 'conversation-two');
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-one',
|
||||
activeConversationId: 'conversation-two',
|
||||
conversation: { conversationId: 'conversation-two' },
|
||||
tasks: [{ taskId: 'task-one' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a lower-revision fresh task event after switching conversations', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
const project = workspace('workspace-one', 50);
|
||||
const { messages: _messages, ...secondSummary } = conversation(
|
||||
'workspace-one',
|
||||
0,
|
||||
'conversation-two',
|
||||
);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
||||
...project,
|
||||
conversationCount: 2,
|
||||
conversations: [...project.conversations, secondSummary],
|
||||
});
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
||||
second.emit('design.generation_task.updated', taskEvent(3, 'running'));
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeConversationId: 'conversation-two',
|
||||
workspace: { viewRevision: 50 },
|
||||
tasks: [{ taskId: 'task-one', status: 'running' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let an older ABA conversation response replace the latest turn', async () => {
|
||||
const source = new MockEventSource();
|
||||
const project = workspace();
|
||||
const { messages: _messages, ...secondSummary } = conversation(
|
||||
'workspace-one',
|
||||
0,
|
||||
'conversation-two',
|
||||
);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
||||
...project,
|
||||
conversationCount: 2,
|
||||
conversations: [...project.conversations, secondSummary],
|
||||
});
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
|
||||
const staleA = deferred<DesignConversation>();
|
||||
const freshA = deferred<DesignConversation>();
|
||||
fetchImageWorkspaceConversationMock
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(staleA.promise)
|
||||
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two'))
|
||||
.mockReturnValueOnce(freshA.promise);
|
||||
const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
||||
const secondA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
||||
freshA.resolve({
|
||||
...conversation('workspace-one', 5, 'conversation-one'),
|
||||
updatedAt: '2026-08-02T10:05:00Z',
|
||||
});
|
||||
await secondA;
|
||||
staleA.resolve({
|
||||
...conversation('workspace-one', 4, 'conversation-one'),
|
||||
updatedAt: '2026-08-02T10:04:00Z',
|
||||
});
|
||||
await firstA;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeConversationId: 'conversation-one',
|
||||
conversation: { conversationId: 'conversation-one', turnRevision: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not surface a late ABA conversation error on the latest selection', async () => {
|
||||
const source = new MockEventSource();
|
||||
const project = workspace();
|
||||
const { messages: _messages, ...secondSummary } = conversation(
|
||||
'workspace-one',
|
||||
0,
|
||||
'conversation-two',
|
||||
);
|
||||
fetchImageWorkspaceProjectMock.mockResolvedValue({
|
||||
...project,
|
||||
conversationCount: 2,
|
||||
conversations: [...project.conversations, secondSummary],
|
||||
});
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
|
||||
let rejectStale!: (error: unknown) => void;
|
||||
const staleA = new Promise<DesignConversation>((_resolve, reject) => {
|
||||
rejectStale = reject;
|
||||
});
|
||||
fetchImageWorkspaceConversationMock
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(staleA)
|
||||
.mockResolvedValueOnce(conversation('workspace-one', 2, 'conversation-two'))
|
||||
.mockResolvedValueOnce(conversation('workspace-one', 5, 'conversation-one'));
|
||||
const firstA = useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-two');
|
||||
await useImageWorkspaceStore.getState().selectConversation('conversation-one');
|
||||
rejectStale(new Error('stale request failed'));
|
||||
await firstA;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeConversationId: 'conversation-one',
|
||||
conversation: { turnRevision: 5 },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let stale ABA workspace tasks replace the latest project load', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
|
||||
const staleTasks = deferred<DesignGenerationTask[]>();
|
||||
fetchImageWorkspaceProjectMock
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce(workspace('workspace-one', 4))
|
||||
.mockResolvedValueOnce(workspace('workspace-two', 1))
|
||||
.mockResolvedValueOnce(workspace('workspace-one', 5));
|
||||
fetchImageWorkspaceTasksMock
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(staleTasks.promise)
|
||||
.mockResolvedValueOnce([{ ...task, taskId: 'task-two', workspaceId: 'workspace-two' }])
|
||||
.mockResolvedValueOnce([{
|
||||
...task,
|
||||
status: 'succeeded',
|
||||
updatedAt: '2026-08-02T11:00:00Z',
|
||||
}]);
|
||||
|
||||
const firstA = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
||||
await vi.waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledOnce());
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-two');
|
||||
await useImageWorkspaceStore.getState().selectProject('workspace-one');
|
||||
staleTasks.resolve([{
|
||||
...task,
|
||||
status: 'failed',
|
||||
updatedAt: '2026-08-02T10:04:00Z',
|
||||
}]);
|
||||
await firstA;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-one',
|
||||
workspace: { workspaceId: 'workspace-one', viewRevision: 5 },
|
||||
tasks: [{ taskId: 'task-one', status: 'succeeded' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents duplicate conversations while one create request is in flight', async () => {
|
||||
const source = new MockEventSource();
|
||||
const pending = deferred<DesignConversation>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
createImageWorkspaceConversationMock.mockReturnValueOnce(pending.promise);
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
|
||||
const firstCreate = useImageWorkspaceStore.getState().createConversation();
|
||||
await expect(useImageWorkspaceStore.getState().createConversation())
|
||||
.rejects.toThrow('正在创建新会话');
|
||||
expect(createImageWorkspaceConversationMock).toHaveBeenCalledOnce();
|
||||
|
||||
pending.resolve(conversation('workspace-one', 0, 'conversation-new'));
|
||||
await firstCreate;
|
||||
expect(useImageWorkspaceStore.getState().creatingConversation).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
@@ -87,18 +94,96 @@ describe('local AI design workspace', () => {
|
||||
|
||||
const reloaded = new LocalImageWorkspace({ userDataDir });
|
||||
await expect(reloaded.bootstrap()).resolves.toMatchObject({
|
||||
workspaces: [{ title: '角色设计第二版', turnRevision: 0, viewRevision: 1 }],
|
||||
workspaces: [{ title: '角色设计第二版', conversationCount: 1, viewRevision: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('migrates a legacy single conversation into schema v3 without losing project tasks', async () => {
|
||||
const userDataDir = createTemporaryDirectory();
|
||||
const rootDirectory = getLocalImageWorkspaceDirectory(userDataDir);
|
||||
const timestamp = '2026-07-31T10:00:00.000Z';
|
||||
mkdirSync(rootDirectory, { recursive: true });
|
||||
writeFileSync(join(rootDirectory, 'design-workspace-v2.json'), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
workspaces: [{
|
||||
workspaceId: 'workspace-legacy',
|
||||
clientWorkspaceId: 'client-legacy',
|
||||
title: '历史设计项目',
|
||||
turnRevision: 2,
|
||||
viewRevision: 4,
|
||||
phase: 'awaiting_confirmation',
|
||||
brief: {
|
||||
version: 2,
|
||||
status: 'ready',
|
||||
medium: 'image',
|
||||
summary: '历史海报',
|
||||
ready: true,
|
||||
missingDecision: null,
|
||||
},
|
||||
messages: [{
|
||||
id: 'message-legacy',
|
||||
role: 'user',
|
||||
kind: 'user',
|
||||
text: '保留这条历史消息',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 1,
|
||||
createdAt: timestamp,
|
||||
}],
|
||||
requestHashes: { 'turn-legacy': 'hash-legacy' },
|
||||
updatedAt: timestamp,
|
||||
}],
|
||||
tasksByWorkspaceId: {
|
||||
'workspace-legacy': [{
|
||||
taskId: 'task-legacy',
|
||||
workspaceId: 'workspace-legacy',
|
||||
medium: 'image',
|
||||
status: 'succeeded',
|
||||
briefVersion: 2,
|
||||
briefSummary: '历史海报',
|
||||
quoteId: null,
|
||||
quotedDesignPoints: null,
|
||||
failureCode: null,
|
||||
resultAssets: [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}],
|
||||
},
|
||||
assetsById: {},
|
||||
}), 'utf8');
|
||||
const service = createService(userDataDir);
|
||||
|
||||
const workspace = await service.getWorkspace('workspace-legacy');
|
||||
const migratedConversation = workspace.conversations[0];
|
||||
|
||||
expect(workspace).toMatchObject({
|
||||
workspaceId: 'workspace-legacy',
|
||||
conversationCount: 1,
|
||||
conversations: [{ title: '历史会话', turnRevision: 2 }],
|
||||
});
|
||||
await expect(service.getConversation(
|
||||
'workspace-legacy',
|
||||
migratedConversation.conversationId,
|
||||
)).resolves.toMatchObject({
|
||||
messages: [{ text: '保留这条历史消息' }],
|
||||
});
|
||||
await expect(service.listTasks('workspace-legacy')).resolves.toMatchObject([{
|
||||
taskId: 'task-legacy',
|
||||
conversationId: migratedConversation.conversationId,
|
||||
}]);
|
||||
expect(existsSync(join(rootDirectory, 'design-workspace-v3.json'))).toBe(true);
|
||||
});
|
||||
|
||||
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: '海洋公益海报',
|
||||
});
|
||||
const conversationId = created.conversations[0].conversationId;
|
||||
const discussed = await service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
conversationId,
|
||||
clientTurnId: 'turn-one',
|
||||
expectedTurnRevision: 0,
|
||||
message: '做一张保护海洋的竖版公益海报',
|
||||
@@ -111,6 +196,7 @@ describe('local AI design workspace', () => {
|
||||
|
||||
const confirmed = await service.confirmGeneration({
|
||||
workspaceId: created.workspaceId,
|
||||
conversationId,
|
||||
clientTurnId: 'turn-two',
|
||||
expectedTurnRevision: 1,
|
||||
quoteId: quote!.quoteId,
|
||||
@@ -141,15 +227,17 @@ describe('local AI design workspace', () => {
|
||||
clientWorkspaceId: 'client-reset',
|
||||
title: '可重置项目',
|
||||
});
|
||||
const conversationId = created.conversations[0].conversationId;
|
||||
|
||||
await expect(service.submitMessage({
|
||||
workspaceId: created.workspaceId,
|
||||
conversationId,
|
||||
clientTurnId: 'stale-turn',
|
||||
expectedTurnRevision: 3,
|
||||
message: '测试',
|
||||
})).rejects.toMatchObject<LocalImageWorkspaceError>({
|
||||
status: 409,
|
||||
code: 'workspace_revision_conflict',
|
||||
code: 'conversation_revision_conflict',
|
||||
});
|
||||
|
||||
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true);
|
||||
|
||||
@@ -376,8 +376,16 @@ describe('Sidebar project initialization flow', () => {
|
||||
const summary = {
|
||||
workspaceId: 'cloud-project',
|
||||
title: '云端概念设计',
|
||||
turnRevision: 0,
|
||||
viewRevision: 0,
|
||||
conversationCount: 1,
|
||||
phase: 'shaping',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
};
|
||||
const conversation = {
|
||||
conversationId: 'cloud-conversation',
|
||||
workspaceId: 'cloud-project',
|
||||
title: '主会话',
|
||||
turnRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
@@ -387,8 +395,21 @@ describe('Sidebar project initialization flow', () => {
|
||||
ready: false,
|
||||
missingDecision: '作品形式',
|
||||
},
|
||||
createdAt: '2026-07-31T10:00:00Z',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
const { messages: _messages, ...conversationSummary } = conversation;
|
||||
const secondConversation = {
|
||||
...conversation,
|
||||
conversationId: 'cloud-conversation-two',
|
||||
title: '第二会话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-31T10:05:00Z',
|
||||
updatedAt: '2026-07-31T10:05:00Z',
|
||||
};
|
||||
const { messages: _secondMessages, ...secondConversationSummary } = secondConversation;
|
||||
let hasSecondConversation = false;
|
||||
const bootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
@@ -403,15 +424,51 @@ describe('Sidebar project initialization flow', () => {
|
||||
return { success: true, data: bootstrap };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project' && !init?.method) {
|
||||
return { success: true, data: { ...summary, messages: [] } };
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...summary,
|
||||
conversationCount: hasSecondConversation ? 2 : 1,
|
||||
conversations: hasSecondConversation
|
||||
? [secondConversationSummary, conversationSummary]
|
||||
: [conversationSummary],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations/cloud-conversation') {
|
||||
return { success: true, data: conversation };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project-two/conversations/cloud-conversation-two') {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...conversation,
|
||||
workspaceId: 'cloud-project-two',
|
||||
conversationId: 'cloud-conversation-two',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project/tasks') {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/cloud-project/conversations'
|
||||
&& init?.method === 'POST') {
|
||||
hasSecondConversation = true;
|
||||
return { success: true, data: secondConversation };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces' && init?.method === 'POST') {
|
||||
return {
|
||||
success: true,
|
||||
data: { ...summary, workspaceId: 'cloud-project-two', title: '角色设定', messages: [] },
|
||||
data: {
|
||||
...summary,
|
||||
workspaceId: 'cloud-project-two',
|
||||
title: '角色设定',
|
||||
conversations: [{
|
||||
...conversationSummary,
|
||||
workspaceId: 'cloud-project-two',
|
||||
conversationId: 'cloud-conversation-two',
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
@@ -428,6 +485,19 @@ describe('Sidebar project initialization flow', () => {
|
||||
expect(screen.queryByText('项目模板')).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/opencode/projects')).toBe(false);
|
||||
expect(screen.queryByText(/添加 Agent/)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/image-workspace/workspaces/cloud-project/conversations',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
));
|
||||
expect(await screen.findByTestId('sidebar-image-conversation-cloud-conversation-two'))
|
||||
.toHaveAttribute('aria-current', 'page');
|
||||
fireEvent.click(screen.getByTestId('sidebar-image-conversation-cloud-conversation'));
|
||||
await waitFor(() => expect(screen.getByTestId('sidebar-image-conversation-cloud-conversation'))
|
||||
.toHaveAttribute('aria-current', 'page'));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sidebar-create-image-project'));
|
||||
expect(screen.getByRole('dialog', { name: '新建设计项目' })).toBeInTheDocument();
|
||||
@@ -456,8 +526,16 @@ describe('Sidebar project initialization flow', () => {
|
||||
const summary = {
|
||||
workspaceId: 'local-project',
|
||||
title: '概念设计',
|
||||
turnRevision: 0,
|
||||
viewRevision: 0,
|
||||
conversationCount: 1,
|
||||
phase: 'shaping',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
};
|
||||
const conversation = {
|
||||
conversationId: 'local-conversation',
|
||||
workspaceId: 'local-project',
|
||||
title: '主会话',
|
||||
turnRevision: 0,
|
||||
phase: 'shaping',
|
||||
brief: {
|
||||
version: 0,
|
||||
@@ -467,8 +545,11 @@ describe('Sidebar project initialization flow', () => {
|
||||
ready: false,
|
||||
missingDecision: '作品形式',
|
||||
},
|
||||
createdAt: '2026-07-31T10:00:00Z',
|
||||
updatedAt: '2026-07-31T10:00:00Z',
|
||||
messages: [],
|
||||
};
|
||||
const { messages: _messages, ...conversationSummary } = conversation;
|
||||
let bootstrap = {
|
||||
capabilities: {
|
||||
conversation: true,
|
||||
@@ -483,7 +564,10 @@ describe('Sidebar project initialization flow', () => {
|
||||
return { success: true, data: bootstrap };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project' && !init?.method) {
|
||||
return { success: true, data: { ...summary, messages: [] } };
|
||||
return { success: true, data: { ...summary, conversations: [conversationSummary] } };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project/conversations/local-conversation') {
|
||||
return { success: true, data: conversation };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project/tasks') {
|
||||
return { success: true, data: [] };
|
||||
@@ -493,11 +577,25 @@ describe('Sidebar project initialization flow', () => {
|
||||
...summary,
|
||||
workspaceId: 'local-project-two',
|
||||
title: '角色设计',
|
||||
messages: [],
|
||||
conversations: [{
|
||||
...conversationSummary,
|
||||
workspaceId: 'local-project-two',
|
||||
conversationId: 'local-conversation-two',
|
||||
}],
|
||||
};
|
||||
bootstrap = { ...bootstrap, workspaces: [created, ...bootstrap.workspaces] };
|
||||
return { success: true, data: created };
|
||||
}
|
||||
if (path === '/api/works/image-workspace/workspaces/local-project-two/conversations/local-conversation-two') {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...conversation,
|
||||
workspaceId: 'local-project-two',
|
||||
conversationId: 'local-conversation-two',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path ${path}`);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user