合并 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'));
|
||||
|
||||
Reference in New Issue
Block a user