feat: 完善图像工作区与创作工具体验
This commit is contained in:
@@ -15,6 +15,7 @@ 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 deleteImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||
@@ -32,6 +33,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
createImageWorkspaceConversation: (...args: unknown[]) => (
|
||||
createImageWorkspaceConversationMock(...args)
|
||||
),
|
||||
deleteImageWorkspaceProject: (...args: unknown[]) => (
|
||||
deleteImageWorkspaceProjectMock(...args)
|
||||
),
|
||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||
@@ -182,6 +186,10 @@ describe('AI design task event store', () => {
|
||||
createImageWorkspaceConversationMock.mockResolvedValue(
|
||||
conversation('workspace-one', 0, 'conversation-new'),
|
||||
);
|
||||
deleteImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => Promise.resolve({
|
||||
workspaceId,
|
||||
deleted: true,
|
||||
}));
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
||||
sendImageWorkspaceMessageMock.mockResolvedValue(conversation());
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||
@@ -275,8 +283,8 @@ describe('AI design task event store', () => {
|
||||
} satisfies DesignConversationSnapshotEvent);
|
||||
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages[0].text)
|
||||
.toBe('可以,先强化鲸鱼的轮廓。');
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages.map(({ text }) => text))
|
||||
.toEqual(['把主视觉改成鲸鱼', '可以,先强化鲸鱼的轮廓。']);
|
||||
response.resolve(canonical);
|
||||
await sending;
|
||||
});
|
||||
@@ -303,6 +311,80 @@ describe('AI design task event store', () => {
|
||||
expect(useImageWorkspaceStore.getState().tasks).toEqual([task]);
|
||||
});
|
||||
|
||||
it('keeps the final confirmation prompt when the canonical response omits the user message', async () => {
|
||||
const source = new MockEventSource();
|
||||
const canonical = {
|
||||
...conversation('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
messages: [{
|
||||
id: 'assistant-confirmed',
|
||||
role: 'assistant' as const,
|
||||
kind: 'reply' as const,
|
||||
text: '设计任务已确认并开始执行。',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-02T10:01:00Z',
|
||||
}],
|
||||
};
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
confirmImageWorkspaceGenerationMock.mockResolvedValueOnce(canonical);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValueOnce([task]);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await useImageWorkspaceStore.getState().confirmGeneration('quote-one');
|
||||
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages.map(({ text }) => text))
|
||||
.toEqual(['确认生成', '设计任务已确认并开始执行。']);
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
||||
});
|
||||
|
||||
it('reconciles the active conversation when returning during an Agent turn', async () => {
|
||||
const source = new MockEventSource();
|
||||
const response = deferred<DesignConversation>();
|
||||
const canonical = {
|
||||
...conversation('workspace-one', 2),
|
||||
turnRevision: 2,
|
||||
messages: [{
|
||||
id: 'assistant-latest',
|
||||
role: 'assistant' as const,
|
||||
kind: 'reply' as const,
|
||||
text: '已完成最新方向整理。',
|
||||
quickReplies: [],
|
||||
generationQuote: null,
|
||||
turnRevision: 2,
|
||||
createdAt: '2026-08-02T10:01:00Z',
|
||||
}],
|
||||
};
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(source.onopen).not.toBeNull());
|
||||
source.onopen?.(new Event('open'));
|
||||
|
||||
const sending = useImageWorkspaceStore.getState().sendMessage('继续优化海报');
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn?.userText).toBe('继续优化海报');
|
||||
|
||||
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(canonical);
|
||||
useImageWorkspaceStore.getState().markTaskStreamNeedsReconciliation();
|
||||
useImageWorkspaceStore.getState().connectTaskStream();
|
||||
|
||||
await vi.waitFor(() => expect(fetchImageWorkspaceConversationMock)
|
||||
.toHaveBeenLastCalledWith('workspace-one', 'conversation-one'));
|
||||
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
|
||||
expect(source.close).not.toHaveBeenCalled();
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages.map(({ text }) => text))
|
||||
.toEqual(['继续优化海报', '已完成最新方向整理。']);
|
||||
expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull();
|
||||
|
||||
response.resolve(canonical);
|
||||
await sending;
|
||||
expect(useImageWorkspaceStore.getState().conversation?.messages.map(({ text }) => text))
|
||||
.toEqual(['继续优化海报', '已完成最新方向整理。']);
|
||||
});
|
||||
|
||||
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
@@ -512,6 +594,232 @@ describe('AI design task event store', () => {
|
||||
.toContain('任务列表同步超时');
|
||||
});
|
||||
|
||||
it('deletes the active project and loads the most recently updated remaining project', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(first as unknown as EventSource)
|
||||
.mockResolvedValueOnce(second as unknown as EventSource);
|
||||
const projects = bootstrap(['workspace-one', 'workspace-three', 'workspace-two']);
|
||||
projects.workspaces = projects.workspaces.map((project) => ({
|
||||
...project,
|
||||
updatedAt: project.workspaceId === 'workspace-two'
|
||||
? '2026-08-05T10:00:00Z'
|
||||
: '2026-08-03T10:00:00Z',
|
||||
}));
|
||||
fetchImageWorkspaceMock.mockResolvedValue(projects);
|
||||
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
||||
Promise.resolve(workspace(workspaceId))
|
||||
));
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().deleteProject('workspace-one'))
|
||||
.resolves.toEqual({ workspaceId: 'workspace-one', deleted: true });
|
||||
|
||||
expect(deleteImageWorkspaceProjectMock).toHaveBeenCalledWith('workspace-one');
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(fetchImageWorkspaceProjectMock).toHaveBeenLastCalledWith('workspace-two');
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
status: 'ready',
|
||||
activeWorkspaceId: 'workspace-two',
|
||||
workspace: { workspaceId: 'workspace-two' },
|
||||
conversation: { workspaceId: 'workspace-two' },
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().bootstrap?.workspaces.map(({ workspaceId }) => (
|
||||
workspaceId
|
||||
))).toEqual(['workspace-two', 'workspace-three']);
|
||||
});
|
||||
|
||||
it('keeps the active context while a non-active project deletion is pending and after success', async () => {
|
||||
const source = new MockEventSource();
|
||||
const deletion = deferred<{ workspaceId: string; deleted: true }>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceMock.mockResolvedValue(bootstrap(['workspace-one', 'workspace-two']));
|
||||
deleteImageWorkspaceProjectMock.mockReturnValueOnce(deletion.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
const before = useImageWorkspaceStore.getState();
|
||||
const deleting = useImageWorkspaceStore.getState().deleteProject('workspace-two');
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-one',
|
||||
workspace: { workspaceId: 'workspace-one' },
|
||||
tasks: [{ taskId: 'task-one', status: 'queued' }],
|
||||
deletingWorkspaceId: 'workspace-two',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().bootstrap).toBe(before.bootstrap);
|
||||
expect(useImageWorkspaceStore.getState().workspace).toBe(before.workspace);
|
||||
await expect(useImageWorkspaceStore.getState().deleteProject('workspace-two'))
|
||||
.rejects.toThrow('正在删除设计项目');
|
||||
expect(deleteImageWorkspaceProjectMock).toHaveBeenCalledOnce();
|
||||
|
||||
deletion.resolve({ workspaceId: 'workspace-two', deleted: true });
|
||||
await deleting;
|
||||
|
||||
expect(source.close).not.toHaveBeenCalled();
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-one',
|
||||
workspace: { workspaceId: 'workspace-one' },
|
||||
tasks: [{ taskId: 'task-one', status: 'queued' }],
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().bootstrap?.workspaces.map(({ workspaceId }) => (
|
||||
workspaceId
|
||||
))).toEqual(['workspace-one']);
|
||||
});
|
||||
|
||||
it('enters the ready empty state after deleting the last project', async () => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
await useImageWorkspaceStore.getState().deleteProject('workspace-one');
|
||||
|
||||
expect(source.close).toHaveBeenCalledOnce();
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
status: 'ready',
|
||||
bootstrap: { workspaces: [] },
|
||||
activeWorkspaceId: null,
|
||||
activeConversationId: null,
|
||||
workspace: null,
|
||||
conversation: null,
|
||||
tasks: [],
|
||||
pendingTurn: null,
|
||||
taskStreamState: 'idle',
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['queued', 'running'] as const)(
|
||||
'allows deleting the active project while a task is %s',
|
||||
async (status) => {
|
||||
const source = new MockEventSource();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([{ ...task, status }]);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await expect(useImageWorkspaceStore.getState().deleteProject('workspace-one'))
|
||||
.resolves.toEqual({ workspaceId: 'workspace-one', deleted: true });
|
||||
|
||||
expect(deleteImageWorkspaceProjectMock).toHaveBeenCalledWith('workspace-one');
|
||||
expect(source.close).toHaveBeenCalledOnce();
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
tasks: [],
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves the project context and exposes a server deletion failure', async () => {
|
||||
const source = new MockEventSource();
|
||||
const error = new ImageWorkspaceApiError(
|
||||
503,
|
||||
'workspace_unavailable',
|
||||
'云端暂时无法删除该项目',
|
||||
);
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
deleteImageWorkspaceProjectMock.mockRejectedValueOnce(error);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
await vi.waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
|
||||
const before = useImageWorkspaceStore.getState();
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().deleteProject('workspace-one'))
|
||||
.rejects.toBe(error);
|
||||
|
||||
expect(source.close).not.toHaveBeenCalled();
|
||||
expect(useImageWorkspaceStore.getState().bootstrap).toBe(before.bootstrap);
|
||||
expect(useImageWorkspaceStore.getState().workspace).toBe(before.workspace);
|
||||
expect(useImageWorkspaceStore.getState().conversation).toBe(before.conversation);
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-one',
|
||||
deletingWorkspaceId: null,
|
||||
error: '云端暂时无法删除该项目',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a deletion completion mutate the store after reset', async () => {
|
||||
const source = new MockEventSource();
|
||||
const deletion = deferred<{ workspaceId: string; deleted: true }>();
|
||||
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
deleteImageWorkspaceProjectMock.mockReturnValueOnce(deletion.promise);
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
const deleting = useImageWorkspaceStore.getState().deleteProject('workspace-one');
|
||||
useImageWorkspaceStore.getState().reset();
|
||||
deletion.resolve({ workspaceId: 'workspace-one', deleted: true });
|
||||
|
||||
await expect(deleting).resolves.toEqual({ workspaceId: 'workspace-one', deleted: true });
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
status: 'idle',
|
||||
bootstrap: null,
|
||||
activeWorkspaceId: null,
|
||||
workspace: null,
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates a slow project selection when that selected project is deleted', async () => {
|
||||
const rootSource = new MockEventSource();
|
||||
const replacementSource = new MockEventSource();
|
||||
const staleWorkspace = deferred<DesignWorkspace>();
|
||||
openImageWorkspaceTaskEventsMock
|
||||
.mockResolvedValueOnce(rootSource as unknown as EventSource)
|
||||
.mockResolvedValueOnce(replacementSource as unknown as EventSource);
|
||||
const projects = bootstrap(['workspace-root', 'workspace-one', 'workspace-two']);
|
||||
projects.workspaces = projects.workspaces.map((project) => ({
|
||||
...project,
|
||||
updatedAt: project.workspaceId === 'workspace-two'
|
||||
? '2026-08-05T10:00:00Z'
|
||||
: '2026-08-03T10:00:00Z',
|
||||
}));
|
||||
fetchImageWorkspaceMock.mockResolvedValue(projects);
|
||||
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||
fetchImageWorkspaceProjectMock.mockImplementation((workspaceId: string) => (
|
||||
workspaceId === 'workspace-one'
|
||||
? staleWorkspace.promise
|
||||
: Promise.resolve(workspace(workspaceId))
|
||||
));
|
||||
|
||||
await useImageWorkspaceStore.getState().load();
|
||||
const staleSelection = useImageWorkspaceStore.getState().selectProject('workspace-one');
|
||||
await vi.waitFor(() => expect(fetchImageWorkspaceProjectMock)
|
||||
.toHaveBeenCalledWith('workspace-one'));
|
||||
|
||||
await useImageWorkspaceStore.getState().deleteProject('workspace-one');
|
||||
staleWorkspace.resolve(workspace('workspace-one', 99));
|
||||
await staleSelection;
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
activeWorkspaceId: 'workspace-two',
|
||||
workspace: { workspaceId: 'workspace-two' },
|
||||
conversation: { workspaceId: 'workspace-two' },
|
||||
deletingWorkspaceId: null,
|
||||
error: null,
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().bootstrap?.workspaces.some(
|
||||
({ workspaceId }) => workspaceId === 'workspace-one',
|
||||
)).toBe(false);
|
||||
expect(openImageWorkspaceTaskEventsMock)
|
||||
.not.toHaveBeenCalledWith('workspace-one', 'conversation-one');
|
||||
});
|
||||
|
||||
it('closes the previous stream when switching workspaces and on reset', async () => {
|
||||
const first = new MockEventSource();
|
||||
const second = new MockEventSource();
|
||||
|
||||
Reference in New Issue
Block a user