feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View File

@@ -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();