feat: 支持 AI 设计项目多会话交互
需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。 实现: - 增加会话选择、新建入口及本地数据迁移 - Conversation 独立消息、Brief、Quote 与流式状态 - 保留项目任务并修复 Task revision 与 ABA 异步竞态 - 保持 Enter 发送、Shift+Enter 换行及 IME 保护
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,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'));
|
||||
|
||||
Reference in New Issue
Block a user