完善客户端模块与工作区能力
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-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -17,7 +17,6 @@ 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());
@@ -63,9 +62,6 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
fetchImageWorkspaceConversation: (...args: unknown[]) => (
fetchImageWorkspaceConversationMock(...args)
),
createImageWorkspaceConversation: (...args: unknown[]) => (
createImageWorkspaceConversationMock(...args)
),
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
@@ -196,7 +192,6 @@ describe('ImageCanvas Workspace-first design experience', () => {
fetchImageWorkspaceMock.mockResolvedValue(bootstrapFixture);
fetchImageWorkspaceProjectMock.mockResolvedValue(workspaceFixture());
fetchImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
createImageWorkspaceConversationMock.mockResolvedValue(conversationFixture());
fetchImageWorkspaceTasksMock.mockResolvedValue([taskFixture]);
sendImageWorkspaceMessageMock.mockResolvedValue(conversationFixture());
confirmImageWorkspaceGenerationMock.mockResolvedValue({
@@ -286,15 +281,26 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready'));
});
it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => {
it('renders the conversation messages edge-to-edge with the unified image/video task list', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByText('云端角色设定')).toBeInTheDocument();
expect(await screen.findByTestId('image-workspace-conversation')).toBeInTheDocument();
expect(screen.getByTestId('image-canvas-page')).toHaveClass('font-sans');
expect(screen.queryByTestId('image-canvas-conversation-select')).not.toBeInTheDocument();
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('方向已经明确');
expect(screen.getByText('设计 Agent')).toBeInTheDocument();
expect(screen.queryByText('设计 Agent', { exact: true })).not.toBeInTheDocument();
expect(screen.queryByText('你', { exact: true })).not.toBeInTheDocument();
const messageRows = screen.getAllByTestId('image-workspace-message');
expect(messageRows).toHaveLength(2);
expect(messageRows[0]).toHaveClass('w-full');
expect(messageRows[1]).toHaveClass('w-full');
expect(messageRows[0].firstElementChild).toHaveClass('w-fit', 'max-w-[85%]');
expect(messageRows[0].querySelector('p')).toHaveClass('chat-message-body');
expect(messageRows[1].querySelector('p')).toHaveClass('chat-message-body');
expect(screen.getByTestId('design-quote-quote-one')).toHaveTextContent('1 设计点');
expect(screen.getByTestId('design-task-list')).toHaveTextContent('图片与视频任务统一展示');
expect(screen.getByTestId('design-task-list')).toHaveTextContent('生成内容');
expect(screen.queryByText('图片与视频任务统一展示')).not.toBeInTheDocument();
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
await waitFor(() => expect(screen.getByRole('img', { name: 'AI 设计生成结果' }))
.toHaveAttribute('src', 'http://127.0.0.1:13210/content?token=host'));
@@ -302,75 +308,57 @@ 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 = {
it('uses the compact logo-only empty state for a new design conversation', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...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
),
);
messages: [],
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
expect(await screen.findByTestId('design-task-task-one')).toHaveTextContent('已完成');
fireEvent.click(screen.getByRole('button', { name: '新建设计会话' }));
const emptyState = await screen.findByTestId('image-workspace-empty-state');
expect(emptyState).toHaveTextContent('今天一起描绘什么?');
expect(within(emptyState).getByRole('img', { name: 'Makelore' })).toBeInTheDocument();
expect(within(emptyState).getAllByRole('img')).toHaveLength(1);
expect(emptyState).not.toHaveTextContent('先聊清楚,再开始创作');
expect(emptyState).not.toHaveTextContent('告诉设计 Agent');
});
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('已完成');
it.each(['consumed', 'expired', 'superseded'] as const)(
'hides quote quick replies when the quote is %s',
async (status) => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
generationQuote: {
...conversation.messages[1].generationQuote!,
status,
},
};
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
fireEvent.change(screen.getByLabelText('切换设计会话'), {
target: { value: 'conversation-cloud' },
});
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await waitFor(() => expect(fetchImageWorkspaceConversationMock)
.toHaveBeenLastCalledWith('workspace-cloud', 'conversation-cloud'));
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveTextContent('方向已经明确');
expect(screen.getByTestId('design-task-task-one')).toHaveTextContent('已完成');
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument();
expect(screen.queryByTestId('design-quote-quote-one')).not.toBeInTheDocument();
},
);
it('hides quote quick replies when the quote payload is gone', async () => {
const conversation = conversationFixture();
conversation.messages[1] = {
...conversation.messages[1],
generationQuote: null,
};
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(conversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '继续调整' })).not.toBeInTheDocument();
});
it('opens a generated image at full size and saves it through Main', async () => {
@@ -418,7 +406,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await screen.findByTestId('image-workspace-conversation');
expect(screen.getByTestId('design-task-list')).toBeInTheDocument();
expect(screen.getByTestId('design-pane-divider')).toHaveClass('lg:block');
expect(screen.getByTestId('design-task-list')).toHaveClass('lg:border-l', 'lg:h-full');
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
@@ -433,12 +421,16 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(screen.getByTestId('image-canvas-page'))
.toHaveClass('h-full', 'overflow-hidden');
expect(screen.getByTestId('design-task-list'))
.toHaveClass('lg:h-full', 'lg:flex', 'lg:flex-col', 'overflow-hidden');
expect(screen.getByTestId('image-workspace-conversation'))
.toHaveClass('overflow-y-auto', 'overscroll-contain');
expect(screen.getByTestId('design-task-list'))
.toHaveClass('lg:h-full', 'overflow-hidden');
expect(screen.getByTestId('design-task-scroll'))
.toHaveClass('lg:h-[calc(100%-57px)]', 'overflow-y-auto', 'overscroll-contain');
.toHaveClass('lg:flex-1', 'lg:max-h-none', 'min-h-0', 'overflow-y-auto', 'overscroll-contain');
expect(screen.getByTestId('image-workspace-conversation-fade'))
.toHaveClass('absolute', 'top-0', 'bg-gradient-to-b');
expect(screen.getByTestId('design-task-fade')).toBeInTheDocument();
expect(screen.getByTestId('design-task-list').firstElementChild).not.toHaveClass('border-b');
});
it('explains the project model before the first Workspace is created', async () => {
@@ -456,7 +448,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('sends text to the Agent with the current Turn revision instead of generating directly', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
await screen.findByTestId('image-workspace-conversation');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '把主角换成暖色轮廓光' },
@@ -475,7 +467,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('sends the design message with Enter', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
await screen.findByTestId('image-workspace-conversation');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
@@ -492,12 +484,75 @@ describe('ImageCanvas Workspace-first design experience', () => {
));
expect(composer).toHaveValue('');
expect(screen.getByTestId('image-workspace-composer'))
.toHaveTextContent('Enter 发送 · Shift+Enter 换行');
.toHaveClass('w-[calc(100%-2rem)]', 'max-w-[46rem]');
expect(screen.getByTestId('image-workspace-composer-surface'))
.toHaveClass('rounded-lg', 'flex', 'min-w-0');
expect(screen.getByRole('button', { name: '上传参考图' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '语音输入' })).toBeDisabled();
expect(screen.queryByText('Enter 发送 · Shift+Enter 换行')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '上传参考图' }));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
expect(screen.getByLabelText('选择参考图文件')).toBeInTheDocument();
});
it('uploads a reference image inline, lets the user mention it, and sends its Asset id', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const file = new File(['image-bytes'], 'ocean-poster.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('选择参考图文件'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
const candidates = await screen.findByTestId('image-workspace-reference-candidates');
expect(candidates).toHaveTextContent('ocean-poster.png');
expect(screen.queryByRole('dialog', { name: '选择视频首帧' })).not.toBeInTheDocument();
fireEvent.click(within(candidates).getByRole('button', { name: '选择参考图 ocean-poster.png' }));
expect(screen.getByLabelText('设计需求')).toHaveValue('@ocean-poster.png ');
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '@ocean-poster.png 请沿用这张图的构图' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
1,
'@ocean-poster.png 请沿用这张图的构图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
});
it('opens the inline reference palette when the user types @', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByTestId('image-workspace-conversation');
const file = new File(['image-bytes'], 'character-sheet.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('选择参考图文件'), {
target: { files: [file] },
});
await screen.findByTestId('image-workspace-reference-candidates');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
target: { value: '请参考 @', selectionStart: 5, selectionEnd: 5 },
});
const palette = await screen.findByRole('listbox', { name: '选择参考图' });
expect(within(palette).getByRole('option', { name: /character-sheet\.png/ })).toBeInTheDocument();
fireEvent.click(within(palette).getByRole('option', { name: /character-sheet\.png/ }));
expect(composer).toHaveValue('请参考 @character-sheet.png ');
});
it('keeps Shift+Enter for newlines and ignores Enter during IME composition', async () => {
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
await screen.findByTestId('image-workspace-conversation');
const composer = screen.getByLabelText('设计需求');
fireEvent.change(composer, {
@@ -514,7 +569,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
const response = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(response.promise);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
await screen.findByText('云端角色设定');
await screen.findByTestId('image-workspace-conversation');
await waitFor(() => expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce());
fireEvent.change(screen.getByLabelText('设计需求'), {
@@ -565,7 +620,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
await waitFor(() => expect(useImageWorkspaceStore.getState().pendingTurn).toBeNull());
});
it('never downgrades confirmation to normal chat when no active Quote exists', async () => {
it('does not expose confirmation quick replies when no active Quote exists', async () => {
const workspaceWithoutQuote = conversationFixture();
workspaceWithoutQuote.messages[1] = {
...workspaceWithoutQuote.messages[1],
@@ -583,11 +638,15 @@ describe('ImageCanvas Workspace-first design experience', () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce(workspaceWithoutQuote);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
const confirmReply = await screen.findByRole('button', { name: '确认生成' });
await screen.findByTestId('image-workspace-conversation');
expect(screen.queryByRole('button', { name: '确认生成' })).not.toBeInTheDocument();
fireEvent.click(confirmReply);
fireEvent.change(screen.getByLabelText('设计需求'), {
target: { value: '确认生成' },
});
fireEvent.click(screen.getByRole('button', { name: '发送给设计 Agent' }));
expect(screen.getByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
expect(await screen.findByRole('alert')).toHaveTextContent('当前没有可确认的生成报价');
expect(screen.getByLabelText('设计需求')).toHaveValue('');
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
expect(confirmImageWorkspaceGenerationMock).not.toHaveBeenCalled();