feat: 支持 AI 设计项目多会话交互

需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。

实现:
- 增加会话选择、新建入口及本地数据迁移
- Conversation 独立消息、Brief、Quote 与流式状态
- 保留项目任务并修复 Task revision 与 ABA 异步竞态
- 保持 Enter 发送、Shift+Enter 换行及 IME 保护
This commit is contained in:
2026-08-06 18:22:15 +08:00
parent 4980894017
commit 03dae62cf3
18 changed files with 1810 additions and 702 deletions

View File

@@ -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,7 @@ import type {
const fetchImageWorkspaceMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceConversationMock = vi.hoisted(() => vi.fn());
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
@@ -57,6 +59,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
...actual,
fetchImageWorkspace: (...args: unknown[]) => fetchImageWorkspaceMock(...args),
fetchImageWorkspaceProject: (...args: unknown[]) => fetchImageWorkspaceProjectMock(...args),
fetchImageWorkspaceConversation: (...args: unknown[]) => (
fetchImageWorkspaceConversationMock(...args)
),
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
@@ -87,8 +92,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 +114,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 +149,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 +191,11 @@ describe('ImageCanvas Workspace-first design experience', () => {
useImageWorkspaceStore.getState().reset();
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
sendImageWorkspaceMessageMock.mockResolvedValue(workspaceFixture());
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
confirmImageWorkspaceGenerationMock.mockResolvedValue({
...workspaceFixture(),
...conversationFixture(),
turnRevision: 2,
phase: 'shaping',
});
@@ -365,6 +386,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'把主角换成暖色轮廓光',
expect.stringMatching(/^turn-/),
@@ -384,6 +406,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'让画面增加一层晨雾',
expect.stringMatching(/^turn-/),
@@ -409,7 +432,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
});
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('云端角色设定');
@@ -428,6 +451,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,
@@ -445,6 +469,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,
@@ -455,15 +480,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,
@@ -477,7 +501,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: '确认生成' });
@@ -510,6 +534,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
@@ -539,6 +564,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(confirmImageWorkspaceGenerationMock)
.toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'quote-one',
expect.stringMatching(/^turn-/),
@@ -560,6 +586,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
question,
expect.stringMatching(/^turn-/),
@@ -568,8 +595,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,
@@ -580,7 +607,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
missingDecision: '请选择首帧来源',
},
messages: [
...workspaceFixture().messages,
...conversationFixture().messages,
{
id: 'message-video-source',
role: 'assistant',
@@ -592,7 +619,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: '从作品列表选择图片' }));
@@ -606,13 +633,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',
@@ -624,7 +651,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: '从作品列表选择图片' }));
@@ -636,6 +663,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
@@ -644,16 +672,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',
@@ -666,7 +694,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: '选择视频首帧' });
@@ -680,6 +708,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传的图片作为视频首帧',
expect.stringMatching(/^turn-/),
@@ -714,7 +743,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'));