实现画布Agent侧边栏聊天
新增画布Agent会话契约、领域规则、SpacetimeDB元数据表和生成绑定。 新增api-server画布Agent会话CRUD、OSS消息文档读写、SSE消息流和生成工具编排。 新增前端右侧Agent对话面板、会话状态hook、SSE客户端、BFF客户端和相关测试。 调整画布侧栏、任务栏、小地图默认状态和面板互斥交互。 补齐画布Agent、OSS消息存储、SSE收口、后端契约和项目记忆文档。
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
|
||||
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
|
||||
|
||||
function createClient(): EditorAgentConversationClient {
|
||||
return {
|
||||
listConversations: vi.fn().mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
createConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:01:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
}),
|
||||
getConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '已经看到画布内容',
|
||||
attachments: [],
|
||||
generations: [],
|
||||
status: 'completed',
|
||||
createdAt: '2026-07-03T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:10.000Z',
|
||||
}),
|
||||
deleteConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
streamMessage: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_conversationId, _payload, options) => {
|
||||
options.onEvent?.({
|
||||
event: 'stage',
|
||||
data: { conversationId: _conversationId, stage: 'responding' },
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId: _conversationId,
|
||||
messageId: 'assistant-stream',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '收到,我会参考这张图。',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'done',
|
||||
data: { conversationId: _conversationId, title: null },
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EditorAgentConversationPanelView', () => {
|
||||
it('manages conversations, attachments and message sending inside the panel', async () => {
|
||||
const client = createClient();
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '角色图层',
|
||||
src: '/generated/role.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
{
|
||||
id: 'layer-local-only',
|
||||
resourceId: '',
|
||||
title: '未入库图层',
|
||||
src: '/local-only.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 256,
|
||||
height: 256,
|
||||
originalWidth: 256,
|
||||
originalHeight: 256,
|
||||
zIndex: 2,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
]}
|
||||
assets={[
|
||||
{
|
||||
id: 'asset-1',
|
||||
label: '素材库角色',
|
||||
src: '/assets/role.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
|
||||
await waitFor(() => {
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
});
|
||||
expect(
|
||||
within(attachmentDialog).queryByRole('checkbox', {
|
||||
name: '选择画布图片 未入库图层',
|
||||
}),
|
||||
).toBeNull();
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: '选择画布图片 角色图层',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '参考附件做像素风' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
|
||||
});
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({
|
||||
text: '参考附件做像素风',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends selected attachments even when the text input is empty', async () => {
|
||||
const client = createClient();
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-1',
|
||||
resourceId: 'resource-1',
|
||||
title: '角色图层',
|
||||
src: '/generated/role.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: '选择画布图片 角色图层',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms conversation deletion from an independent dialog', async () => {
|
||||
const client = createClient();
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
projectId="project-1"
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
|
||||
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
|
||||
fireEvent.click(
|
||||
within(confirmDialog).getByRole('button', { name: '确认删除' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,8 +136,10 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
hoveredLayerId: null,
|
||||
canvasMarquee: null,
|
||||
canvasGenerationDialogs: [],
|
||||
editorAgentAssets: [],
|
||||
taskListRefreshKey: 0,
|
||||
isTaskSidebarOpen: false,
|
||||
isAgentConversationOpen: false,
|
||||
generateDialog: null,
|
||||
cropExpandPanel: null,
|
||||
cropExpandSourceLayer: null,
|
||||
@@ -181,6 +183,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onFocusExternalTask: vi.fn(),
|
||||
onToggleTaskSidebar: vi.fn(),
|
||||
onToggleAgentConversation: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
onOpenRedrawPanel: vi.fn(),
|
||||
|
||||
@@ -16,12 +16,19 @@ import {
|
||||
ApiClientError,
|
||||
AuthUiContext,
|
||||
createAuthValue,
|
||||
defaultEditorProjectLayers,
|
||||
defaultEditorProjectResources,
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
readZipText,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
} from './ImageCanvasEditorView.test-utils';
|
||||
|
||||
const listEditorAgentConversationsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAgentConversationMock = vi.hoisted(() => vi.fn());
|
||||
const getEditorAgentConversationMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorAgentConversationMock = vi.hoisted(() => vi.fn());
|
||||
const streamEditorAgentMessageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -69,6 +76,75 @@ vi.mock('../../services/platform-entry/platformProfileClient', () => ({
|
||||
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorAgentClient', () => ({
|
||||
createEditorAgentConversation: createEditorAgentConversationMock,
|
||||
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
|
||||
getEditorAgentConversation: getEditorAgentConversationMock,
|
||||
listEditorAgentConversations: listEditorAgentConversationsMock,
|
||||
streamEditorAgentMessage: streamEditorAgentMessageMock,
|
||||
}));
|
||||
|
||||
function createEditorAgentConversationSummary(overrides = {}) {
|
||||
return {
|
||||
conversationId: 'editor-agent-conv-test',
|
||||
projectId: 'editor-project-default',
|
||||
title: '画布 Agent',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createEditorAgentDetailWithGeneration(resourceId: string) {
|
||||
const summary = createEditorAgentConversationSummary();
|
||||
return {
|
||||
...summary,
|
||||
messages: [
|
||||
{
|
||||
id: 'agent-message-result',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '生成完成',
|
||||
attachments: [],
|
||||
generations: [
|
||||
{
|
||||
toolCallId: 'agent-tool-call-1',
|
||||
toolName: 'generate_image',
|
||||
taskId: 'task-agent-1',
|
||||
status: 'completed',
|
||||
model: 'gpt-image-2',
|
||||
images: [
|
||||
{
|
||||
resourceId,
|
||||
imageSrc: '/agent-result.png',
|
||||
thumbnailSrc: '/agent-result-thumb.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
createdAt: '2026-07-03T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function openAssetSidebar() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
return screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
}
|
||||
|
||||
function openMinimap() {
|
||||
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
fireEvent.click(
|
||||
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
|
||||
);
|
||||
return screen.getByRole('button', { name: '画布小地图' });
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView', () => {
|
||||
setupImageCanvasEditorViewTestLifecycle({
|
||||
generateEditorImageMock,
|
||||
@@ -90,6 +166,21 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
listEditorAgentConversationsMock.mockResolvedValue([]);
|
||||
createEditorAgentConversationMock.mockResolvedValue({
|
||||
...createEditorAgentConversationSummary({
|
||||
conversationId: 'editor-agent-conv-created',
|
||||
}),
|
||||
messages: [],
|
||||
});
|
||||
getEditorAgentConversationMock.mockResolvedValue({
|
||||
...createEditorAgentConversationSummary(),
|
||||
messages: [],
|
||||
});
|
||||
deleteEditorAgentConversationMock.mockResolvedValue(
|
||||
createEditorAgentConversationSummary(),
|
||||
);
|
||||
streamEditorAgentMessageMock.mockResolvedValue(undefined);
|
||||
getPlatformProfileDashboardMock.mockResolvedValue({
|
||||
walletBalance: 1234,
|
||||
totalPlayTimeMs: 0,
|
||||
@@ -99,6 +190,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listEditorAgentConversationsMock.mockReset();
|
||||
createEditorAgentConversationMock.mockReset();
|
||||
getEditorAgentConversationMock.mockReset();
|
||||
deleteEditorAgentConversationMock.mockReset();
|
||||
streamEditorAgentMessageMock.mockReset();
|
||||
getPlatformProfileDashboardMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -164,11 +260,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成图片' }),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('工具栏在这里,快试着随便做点什么'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
|
||||
});
|
||||
expect(bottomToolbar.className).not.toContain(
|
||||
'image-canvas-editor__bottom-toolbar--guided',
|
||||
@@ -195,9 +291,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '生成游戏背景音乐' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('工具栏在这里,快试着随便做点什么'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText('工具栏在这里,快试着随便做点什么')).toBeNull();
|
||||
expect(window.location.search).toBe('?projectid=editor-project-music');
|
||||
});
|
||||
|
||||
@@ -408,6 +502,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
openAssetSidebar();
|
||||
|
||||
expect(
|
||||
await screen.findByRole('region', { name: '项目素材' }),
|
||||
@@ -677,9 +772,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '裁扩图片' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '自由比例' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
screen
|
||||
.getByRole('button', { name: '自由比例' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.queryByLabelText('裁扩左')).toBeNull();
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBeTruthy();
|
||||
@@ -770,34 +865,54 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('downloads the selected layer from the floating toolbar', () => {
|
||||
it('downloads the selected layer from the floating toolbar', async () => {
|
||||
let downloadName = '';
|
||||
let downloadHref = '';
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCreateObjectUrl = URL.createObjectURL;
|
||||
const originalRevokeObjectUrl = URL.revokeObjectURL;
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
return new Response(new Blob(['puzzle'], { type: 'image/webp' }));
|
||||
}) as typeof fetch;
|
||||
URL.createObjectURL = vi.fn(() => 'blob:layer-download');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
downloadHref = this.href;
|
||||
},
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
try {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(
|
||||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||||
{
|
||||
button: 0,
|
||||
pointerId: 51,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
|
||||
fireEvent.pointerDown(
|
||||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||||
{
|
||||
button: 0,
|
||||
pointerId: 51,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
|
||||
|
||||
expect(downloadName).toBe('拼图素材.webp');
|
||||
expect(downloadHref).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(downloadName).toBe('拼图素材.webp');
|
||||
});
|
||||
expect(downloadHref).toBe('blob:layer-download');
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/creation-type-references/puzzle.webp'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
URL.createObjectURL = originalCreateObjectUrl;
|
||||
URL.revokeObjectURL = originalRevokeObjectUrl;
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes the selected layer with Backspace when focus is outside text inputs', async () => {
|
||||
@@ -852,7 +967,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
{ title: 'duration', value: '8秒' },
|
||||
],
|
||||
references: [
|
||||
{ label: '历史参考图', src: '/creation-type-references/puzzle.webp' },
|
||||
{
|
||||
label: '历史参考图',
|
||||
src: '/creation-type-references/puzzle.webp',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -878,7 +996,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '音效时长 8秒' }),
|
||||
).toBeTruthy();
|
||||
expect(panel.querySelector('.image-canvas-editor__reference-strip')).toBeNull();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__reference-strip'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('deletes the selected audio layer with Backspace from the audio control focus', async () => {
|
||||
@@ -1142,7 +1262,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
it('switches the shared sidebar between assets and layers', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
||||
).toBeNull();
|
||||
const sidebar = openAssetSidebar();
|
||||
expect(within(sidebar).getByText('素材')).toBeTruthy();
|
||||
expect(within(sidebar).queryByText('已生成文件')).toBeNull();
|
||||
expect(within(sidebar).queryByText('图层')).toBeNull();
|
||||
@@ -1186,6 +1309,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
screen.getByRole('button', { name: '当前缩放比例 116%' }),
|
||||
).toBeTruthy();
|
||||
|
||||
openAssetSidebar();
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
||||
|
||||
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
||||
@@ -1219,6 +1343,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
openAssetSidebar();
|
||||
await screen.findByRole('button', { name: '添加大图素材' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加大图素材' }));
|
||||
|
||||
@@ -1324,11 +1449,14 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: '画布背景色',
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
expect(backgroundButton.className).toContain('platform-icon-button');
|
||||
expect(
|
||||
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
|
||||
).toBeTruthy();
|
||||
const minimapToggle = within(panelToolbar).getByRole('button', {
|
||||
name: '切换小地图',
|
||||
});
|
||||
expect(minimapToggle).toBeTruthy();
|
||||
fireEvent.click(minimapToggle);
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(backgroundButton);
|
||||
const settingsPanel = screen.getByRole('dialog', {
|
||||
@@ -1379,12 +1507,93 @@ describe('ImageCanvasEditorView', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
|
||||
);
|
||||
fireEvent.click(minimapToggle);
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
});
|
||||
|
||||
it('focuses an existing canvas layer from an Agent generation thumbnail', async () => {
|
||||
const detail = createEditorAgentDetailWithGeneration('resource-puzzle');
|
||||
listEditorAgentConversationsMock.mockResolvedValueOnce([
|
||||
createEditorAgentConversationSummary(),
|
||||
]);
|
||||
getEditorAgentConversationMock.mockResolvedValueOnce(detail);
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '生成结果' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
|
||||
).toContain('image-canvas-editor__layer--selected');
|
||||
});
|
||||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the project and focuses a newly added Agent result layer', async () => {
|
||||
const detail = createEditorAgentDetailWithGeneration('resource-agent-new');
|
||||
listEditorAgentConversationsMock.mockResolvedValueOnce([
|
||||
createEditorAgentConversationSummary(),
|
||||
]);
|
||||
getEditorAgentConversationMock.mockResolvedValueOnce(detail);
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
...defaultEditorProjectLayers,
|
||||
{
|
||||
layerId: 'layer-agent-new',
|
||||
resourceId: 'resource-agent-new',
|
||||
title: 'Agent结果图',
|
||||
x: 1500,
|
||||
y: 300,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 3,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
...defaultEditorProjectResources,
|
||||
{
|
||||
resourceId: 'resource-agent-new',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/agent-result.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'Agent生成结果',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'task-agent-1',
|
||||
assetKind: 'editor_agent_generated_image',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '生成结果' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByAltText('画布图片:Agent结果图').closest('button')
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__layer--selected');
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the canvas view without forwarding the click event to fit layers', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1493,7 +1702,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
it('drags the minimap to move the canvas viewport', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const minimap = screen.getByRole('button', { name: '画布小地图' });
|
||||
const minimap = openMinimap();
|
||||
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1519,10 +1728,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps minimap drag direction stable after pausing and reversing', () => {
|
||||
it('keeps minimap drag direction stable after pausing and reversing', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const minimap = screen.getByRole('button', { name: '画布小地图' });
|
||||
const minimap = openMinimap();
|
||||
vi.spyOn(minimap, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -1554,6 +1763,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientX: 120,
|
||||
clientY: 42,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(readTranslateX()).toBeLessThan(0);
|
||||
});
|
||||
const translateAfterRightDrag = readTranslateX();
|
||||
|
||||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||||
@@ -1563,7 +1775,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
clientY: 42,
|
||||
});
|
||||
|
||||
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
|
||||
await waitFor(() => {
|
||||
expect(readTranslateX()).toBeGreaterThan(translateAfterRightDrag);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists layer groups in the canvas layer snapshot', async () => {
|
||||
@@ -1775,6 +1989,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
openAssetSidebar();
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
||||
|
||||
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
||||
@@ -1797,6 +2012,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
it('supports undo and redo keyboard shortcuts inside the editor', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
openAssetSidebar();
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加声浪素材' }));
|
||||
expect(screen.getByAltText('画布图片:声浪素材')).toBeTruthy();
|
||||
|
||||
|
||||
@@ -9,14 +9,15 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
loadEditorProject,
|
||||
loadEditorGenerationPricing,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -48,6 +49,7 @@ import type {
|
||||
EditorAsset,
|
||||
ImageContextMenuState,
|
||||
QuickEditPanelState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||
@@ -55,11 +57,11 @@ import {
|
||||
applyEditorGenerationPricingConfig,
|
||||
isCanvasGenerationDialog,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveQuickEditFocusViewport,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
getCanvasGenerationSelectionId,
|
||||
getSelectedGenerationDialogIds,
|
||||
@@ -162,7 +164,9 @@ function openCanvasStartupTool(
|
||||
return;
|
||||
}
|
||||
if (tool === 'publication-cover') {
|
||||
generationSurface.openPublicationGenerationDialog('publication-cover-image');
|
||||
generationSurface.openPublicationGenerationDialog(
|
||||
'publication-cover-image',
|
||||
);
|
||||
return;
|
||||
}
|
||||
generationSurface.openGenerateDialog();
|
||||
@@ -460,7 +464,7 @@ export function ImageCanvasEditorView() {
|
||||
});
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user) {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalanceLabel(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
@@ -987,16 +991,16 @@ export function ImageCanvasEditorView() {
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
} = useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
openEditorLoginModal,
|
||||
});
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
openEditorLoginModal,
|
||||
});
|
||||
const applyGeneratedProjectSnapshot = useCallback(
|
||||
(project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(project);
|
||||
@@ -1004,6 +1008,41 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[applyProjectSnapshot, refreshAssetLibrary],
|
||||
);
|
||||
const focusCanvasLayerByResourceId = useCallback(
|
||||
(resourceId: string) => {
|
||||
const targetLayer = layersRef.current.find(
|
||||
(layer) => layer.resourceId === resourceId || layer.id === resourceId,
|
||||
);
|
||||
if (targetLayer) {
|
||||
focusCanvasLayerById(targetLayer.id);
|
||||
return;
|
||||
}
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
void loadEditorProject(projectId)
|
||||
.then((project) => {
|
||||
applyGeneratedProjectSnapshot(project);
|
||||
const targetLayerItem = project.layers.find(
|
||||
(layer) =>
|
||||
layer.resourceId === resourceId || layer.layerId === resourceId,
|
||||
);
|
||||
const targetLayerId = targetLayerItem?.layerId;
|
||||
if (!targetLayerId) {
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
focusCanvasLayerById(targetLayerId);
|
||||
}, 0);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[
|
||||
applyGeneratedProjectSnapshot,
|
||||
focusCanvasLayerById,
|
||||
projectId,
|
||||
],
|
||||
);
|
||||
const persistUpdatedLayerResource = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (!projectId) {
|
||||
@@ -1135,6 +1174,44 @@ export function ImageCanvasEditorView() {
|
||||
applyProjectSnapshot: applyGeneratedProjectSnapshot,
|
||||
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
|
||||
});
|
||||
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
|
||||
const toggleAgentConversation = useCallback(() => {
|
||||
const nextOpen = !isAgentConversationOpen;
|
||||
if (nextOpen) {
|
||||
setActiveSidebarPanel(null);
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
}
|
||||
setIsAgentConversationOpen(nextOpen);
|
||||
}, [generationSurface, isAgentConversationOpen, setActiveSidebarPanel]);
|
||||
const toggleTaskSidebar = useCallback(() => {
|
||||
if (!generationSurface.isTaskSidebarOpen) {
|
||||
setActiveSidebarPanel(null);
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}, [generationSurface, setActiveSidebarPanel]);
|
||||
const toggleCanvasSidebarPanel = useCallback(
|
||||
(panel: SidebarPanel) => {
|
||||
setIsAgentConversationOpen(false);
|
||||
if (generationSurface.isTaskSidebarOpen) {
|
||||
generationSurface.toggleTaskSidebar();
|
||||
}
|
||||
toggleSidebarPanel(panel);
|
||||
},
|
||||
[generationSurface, toggleSidebarPanel],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (generationSurface.isTaskSidebarOpen && isAgentConversationOpen) {
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
}, [generationSurface.isTaskSidebarOpen, isAgentConversationOpen]);
|
||||
useEffect(() => {
|
||||
if (activeSidebarPanel && isAgentConversationOpen) {
|
||||
setIsAgentConversationOpen(false);
|
||||
}
|
||||
}, [activeSidebarPanel, isAgentConversationOpen]);
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
setCropExpandPanel,
|
||||
@@ -1852,9 +1929,11 @@ export function ImageCanvasEditorView() {
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
editorAgentAssets: assets,
|
||||
isToolbarGuideVisible,
|
||||
taskListRefreshKey: generationSurface.taskListRefreshKey,
|
||||
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
|
||||
isAgentConversationOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
@@ -1902,7 +1981,9 @@ export function ImageCanvasEditorView() {
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onFocusEditorAgentResource: focusCanvasLayerByResourceId,
|
||||
onToggleTaskSidebar: toggleTaskSidebar,
|
||||
onToggleAgentConversation: toggleAgentConversation,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openQuickEditPanel,
|
||||
onOpenRedrawPanel: openRedrawPanel,
|
||||
@@ -1948,7 +2029,7 @@ export function ImageCanvasEditorView() {
|
||||
onToggleBackgroundSettings: toggleBackgroundSettings,
|
||||
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
|
||||
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
|
||||
onToggleSidebarPanel: toggleSidebarPanel,
|
||||
onToggleSidebarPanel: toggleCanvasSidebarPanel,
|
||||
onToggleMinimap: toggleMinimap,
|
||||
onMinimapPointerDown: handleMinimapPointerDown,
|
||||
onSwitchTool: switchTool,
|
||||
|
||||
@@ -18,6 +18,7 @@ function renderPanelDock(
|
||||
isZoomMenuOpen: false,
|
||||
isBackgroundSettingsOpen: false,
|
||||
activeSidebarPanel: null,
|
||||
isAgentConversationOpen: false,
|
||||
isMinimapOpen: false,
|
||||
minimapModel: null,
|
||||
onFitLayers: vi.fn(),
|
||||
@@ -30,6 +31,7 @@ function renderPanelDock(
|
||||
onApplyCanvasBackgroundColor: vi.fn(),
|
||||
onCanvasBackgroundHexChange: vi.fn(),
|
||||
onToggleSidebarPanel: vi.fn(),
|
||||
onToggleAgentConversation: vi.fn(),
|
||||
onToggleMinimap: vi.fn(),
|
||||
onMinimapPointerDown: vi.fn(),
|
||||
...overrides,
|
||||
@@ -56,13 +58,15 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
const toolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '打开素材' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
within(toolbar)
|
||||
.getByRole('button', { name: '打开素材' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
(
|
||||
within(toolbar).getByRole('button', { name: '重做' }) as HTMLButtonElement
|
||||
within(toolbar).getByRole('button', {
|
||||
name: '重做',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
|
||||
@@ -70,11 +74,17 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '切换小地图' }));
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '画布 Agent' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(toolbar).getByRole('button', { name: '切换小地图' }),
|
||||
);
|
||||
|
||||
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
|
||||
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
|
||||
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
|
||||
expect(props.onToggleAgentConversation).toHaveBeenCalledTimes(1);
|
||||
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -110,7 +120,9 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
target: { value: '#abc' },
|
||||
});
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '恢复默认' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '关闭画布背景设置' }));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '关闭画布背景设置' }),
|
||||
);
|
||||
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ImagePlus,
|
||||
Layers,
|
||||
Map as MapIcon,
|
||||
MessageCircle,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
@@ -9,18 +10,21 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
|
||||
|
||||
type ImageCanvasPanelDockViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
@@ -31,6 +35,7 @@ type ImageCanvasPanelDockViewProps = {
|
||||
isZoomMenuOpen: boolean;
|
||||
isBackgroundSettingsOpen: boolean;
|
||||
activeSidebarPanel: SidebarPanel | null;
|
||||
isAgentConversationOpen: boolean;
|
||||
isMinimapOpen: boolean;
|
||||
minimapModel: StageMinimapModel | null;
|
||||
onFitLayers: () => void;
|
||||
@@ -43,6 +48,7 @@ type ImageCanvasPanelDockViewProps = {
|
||||
onApplyCanvasBackgroundColor: (color: string) => void;
|
||||
onCanvasBackgroundHexChange: (value: string) => void;
|
||||
onToggleSidebarPanel: (panel: SidebarPanel) => void;
|
||||
onToggleAgentConversation: () => void;
|
||||
onToggleMinimap: () => void;
|
||||
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
@@ -56,6 +62,7 @@ export function ImageCanvasPanelDockView({
|
||||
isZoomMenuOpen,
|
||||
isBackgroundSettingsOpen,
|
||||
activeSidebarPanel,
|
||||
isAgentConversationOpen,
|
||||
isMinimapOpen,
|
||||
minimapModel,
|
||||
onFitLayers,
|
||||
@@ -68,6 +75,7 @@ export function ImageCanvasPanelDockView({
|
||||
onApplyCanvasBackgroundColor,
|
||||
onCanvasBackgroundHexChange,
|
||||
onToggleSidebarPanel,
|
||||
onToggleAgentConversation,
|
||||
onToggleMinimap,
|
||||
onMinimapPointerDown,
|
||||
}: ImageCanvasPanelDockViewProps) {
|
||||
@@ -259,7 +267,9 @@ export function ImageCanvasPanelDockView({
|
||||
type="button"
|
||||
className="image-canvas-editor__background-reset"
|
||||
onClick={() =>
|
||||
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
||||
onApplyCanvasBackgroundColor(
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@@ -283,6 +293,13 @@ export function ImageCanvasPanelDockView({
|
||||
pressed={activeSidebarPanel === 'layers'}
|
||||
onClick={() => onToggleSidebarPanel('layers')}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="画布 Agent"
|
||||
title="画布 Agent"
|
||||
icon={MessageCircle}
|
||||
pressed={isAgentConversationOpen}
|
||||
onClick={onToggleAgentConversation}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="切换小地图"
|
||||
title="小地图"
|
||||
|
||||
@@ -157,6 +157,7 @@ function SidebarTabsHarness() {
|
||||
isZoomMenuOpen={false}
|
||||
isBackgroundSettingsOpen={false}
|
||||
activeSidebarPanel={chrome.activeSidebarPanel}
|
||||
isAgentConversationOpen={false}
|
||||
isMinimapOpen={false}
|
||||
minimapModel={null}
|
||||
onFitLayers={vi.fn()}
|
||||
@@ -169,6 +170,7 @@ function SidebarTabsHarness() {
|
||||
onApplyCanvasBackgroundColor={vi.fn()}
|
||||
onCanvasBackgroundHexChange={vi.fn()}
|
||||
onToggleSidebarPanel={chrome.toggleSidebarPanel}
|
||||
onToggleAgentConversation={vi.fn()}
|
||||
onToggleMinimap={vi.fn()}
|
||||
onMinimapPointerDown={vi.fn()}
|
||||
/>
|
||||
@@ -258,8 +260,12 @@ describe('ImageCanvasSidebarView', () => {
|
||||
target: { value: '火焰' },
|
||||
});
|
||||
|
||||
expect(within(sidebar).getByRole('button', { name: '添加火焰按钮' })).toBeTruthy();
|
||||
expect(within(sidebar).queryByRole('button', { name: '添加账号素材A' })).toBeNull();
|
||||
expect(
|
||||
within(sidebar).getByRole('button', { name: '添加火焰按钮' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(sidebar).queryByRole('button', { name: '添加账号素材A' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the layer panel with grouped and locked layer details', () => {
|
||||
@@ -320,6 +326,12 @@ describe('ImageCanvasSidebarView', () => {
|
||||
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
|
||||
render(<SidebarTabsHarness />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: '图片资源栏' }),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import type {
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
|
||||
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasClipboard,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
CanvasViewport,
|
||||
CropExpandPanelState,
|
||||
CropExpandResizeHandle,
|
||||
EditorAsset,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
@@ -55,9 +57,11 @@ export type ImageCanvasStageViewProps = {
|
||||
hoveredLayerId: string | null;
|
||||
canvasMarquee: CanvasMarqueeState | null;
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
editorAgentAssets?: EditorAsset[];
|
||||
isToolbarGuideVisible?: boolean;
|
||||
taskListRefreshKey: number;
|
||||
isTaskSidebarOpen: boolean;
|
||||
isAgentConversationOpen: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
cropExpandPanel: CropExpandPanelState | null;
|
||||
cropExpandSourceLayer: CanvasLayer | null;
|
||||
@@ -116,7 +120,9 @@ export type ImageCanvasStageViewProps = {
|
||||
) => void;
|
||||
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
|
||||
onFocusEditorAgentResource?: (resourceId: string) => void;
|
||||
onToggleTaskSidebar: () => void;
|
||||
onToggleAgentConversation: () => void;
|
||||
onCropExpandHandlePointerDown: (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
handle: CropExpandResizeHandle,
|
||||
@@ -195,9 +201,11 @@ export function ImageCanvasStageView({
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
editorAgentAssets = [],
|
||||
isToolbarGuideVisible = false,
|
||||
taskListRefreshKey,
|
||||
isTaskSidebarOpen,
|
||||
isAgentConversationOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel,
|
||||
cropExpandSourceLayer,
|
||||
@@ -241,7 +249,9 @@ export function ImageCanvasStageView({
|
||||
onGenerationFramePointerDown,
|
||||
onActivateGenerationDialog,
|
||||
onFocusExternalTask,
|
||||
onFocusEditorAgentResource,
|
||||
onToggleTaskSidebar,
|
||||
onToggleAgentConversation,
|
||||
onCropExpandHandlePointerDown,
|
||||
onOpenQuickEditPanel,
|
||||
onOpenRedrawPanel,
|
||||
@@ -422,6 +432,7 @@ export function ImageCanvasStageView({
|
||||
isZoomMenuOpen={isZoomMenuOpen}
|
||||
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
|
||||
activeSidebarPanel={activeSidebarPanel}
|
||||
isAgentConversationOpen={isAgentConversationOpen}
|
||||
isMinimapOpen={isMinimapOpen}
|
||||
minimapModel={minimapModel}
|
||||
onFitLayers={onFitLayers}
|
||||
@@ -434,6 +445,7 @@ export function ImageCanvasStageView({
|
||||
onApplyCanvasBackgroundColor={onApplyCanvasBackgroundColor}
|
||||
onCanvasBackgroundHexChange={onCanvasBackgroundHexChange}
|
||||
onToggleSidebarPanel={onToggleSidebarPanel}
|
||||
onToggleAgentConversation={onToggleAgentConversation}
|
||||
onToggleMinimap={onToggleMinimap}
|
||||
onMinimapPointerDown={onMinimapPointerDown}
|
||||
/>
|
||||
@@ -446,6 +458,15 @@ export function ImageCanvasStageView({
|
||||
onFocusExternalTask={onFocusExternalTask}
|
||||
/>
|
||||
|
||||
<EditorAgentConversationPanelView
|
||||
projectId={projectId}
|
||||
open={isAgentConversationOpen}
|
||||
onToggleOpen={onToggleAgentConversation}
|
||||
layers={layers}
|
||||
assets={editorAgentAssets}
|
||||
onFocusResource={onFocusEditorAgentResource}
|
||||
/>
|
||||
|
||||
{isToolbarGuideVisible ? (
|
||||
<div
|
||||
className="image-canvas-editor__toolbar-guide"
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type EditorAgentConversationClient,
|
||||
useEditorAgentConversation,
|
||||
} from './useEditorAgentConversation';
|
||||
|
||||
function createClient(): EditorAgentConversationClient {
|
||||
return {
|
||||
listConversations: vi.fn().mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
createConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
}),
|
||||
getConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
deleteConversation: vi.fn().mockResolvedValue({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
streamMessage: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_conversationId, _payload, options) => {
|
||||
options.onEvent?.({
|
||||
event: 'stage',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
stage: 'thinking',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '我来处理',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'tool_started',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'generate_image',
|
||||
taskId: 'task-1',
|
||||
model: 'gpt-image-2',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'generation_result',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-1',
|
||||
toolCallId: 'tool-call-1',
|
||||
toolName: 'generate_image',
|
||||
model: null,
|
||||
images: [
|
||||
{
|
||||
resourceId: 'resource-result-1',
|
||||
imageSrc: '/result.png',
|
||||
thumbnailSrc: '/result-thumb.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'done',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
title: '像素角色',
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useEditorAgentConversation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads conversations and applies message stream events', async () => {
|
||||
const client = createClient();
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversation?.conversationId).toBe(
|
||||
'conversation-1',
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('把这个角色改成像素风');
|
||||
});
|
||||
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '把这个角色改成像素风',
|
||||
attachments: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onEvent: expect.any(Function),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(result.current.stage).toBe('completed');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages.map((message) => message.text)).toEqual([
|
||||
'把这个角色改成像素风',
|
||||
'我来处理',
|
||||
]);
|
||||
expect(result.current.messages[1]?.generations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: 'tool-call-1',
|
||||
taskId: 'task-1',
|
||||
model: 'gpt-image-2',
|
||||
status: 'completed',
|
||||
images: [
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-result-1',
|
||||
thumbnailSrc: '/result-thumb.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.conversations[0]?.title).toBe('像素角色');
|
||||
});
|
||||
|
||||
it('creates a conversation before sending when the project has no history', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingConversations).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('新建后发送');
|
||||
});
|
||||
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({ text: '新建后发送' }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows sending an attachment-only message', async () => {
|
||||
const client = createClient();
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversation?.conversationId).toBe(
|
||||
'conversation-1',
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('', [
|
||||
{
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
objectKey: 'generated-editor-assets/resource-1.png',
|
||||
imageSrc: '/resource-1.png',
|
||||
label: '参考图',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(client.streamMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a failed tool completion as a generation record', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.streamMessage).mockImplementation(
|
||||
async (_conversationId, _payload, options) => {
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-failed',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '我先尝试生成。',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'tool_started',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-failed',
|
||||
toolCallId: 'tool-call-failed',
|
||||
toolName: 'generate_image',
|
||||
taskId: null,
|
||||
model: 'gpt-image-2',
|
||||
status: 'generating',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'tool_completed',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-failed',
|
||||
toolCallId: 'tool-call-failed',
|
||||
toolName: 'generate_image',
|
||||
taskId: null,
|
||||
model: 'gpt-image-2',
|
||||
status: 'failed',
|
||||
error: '泥点余额不足',
|
||||
},
|
||||
});
|
||||
options.onEvent?.({
|
||||
event: 'error',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
message: '泥点余额不足',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversation?.conversationId).toBe(
|
||||
'conversation-1',
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.sendMessage('生成一张图'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
const failedMessage = result.current.messages.find(
|
||||
(message) => message.id === 'assistant-failed',
|
||||
);
|
||||
expect(failedMessage?.status).toBe('failed');
|
||||
expect(failedMessage?.generations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: 'tool-call-failed',
|
||||
status: 'failed',
|
||||
model: 'gpt-image-2',
|
||||
error: '泥点余额不足',
|
||||
}),
|
||||
]);
|
||||
expect(result.current.errorMessage).toBe('泥点余额不足');
|
||||
});
|
||||
|
||||
it('aborts the active stream and marks streaming messages as stopped', async () => {
|
||||
const client = createClient();
|
||||
let streamSignal: AbortSignal | null = null;
|
||||
vi.mocked(client.streamMessage).mockImplementation(
|
||||
(_conversationId, _payload, options) =>
|
||||
new Promise<void>((resolve) => {
|
||||
streamSignal = options.signal ?? null;
|
||||
options.onEvent?.({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'assistant-streaming',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
textDelta: '处理中',
|
||||
},
|
||||
});
|
||||
streamSignal?.addEventListener('abort', () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversation?.conversationId).toBe(
|
||||
'conversation-1',
|
||||
);
|
||||
});
|
||||
|
||||
void act(() => {
|
||||
void result.current.sendMessage('请继续');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.stopCurrentTurn();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(streamSignal?.aborted).toBe(true);
|
||||
});
|
||||
expect(result.current.messages.at(-1)?.status).toBe('stopped');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,9 @@ describe('useImageCanvasEditorChrome', () => {
|
||||
it('manages background colors and chrome panel toggles', () => {
|
||||
const { result } = renderChrome();
|
||||
|
||||
expect(result.current.activeSidebarPanel).toBeNull();
|
||||
expect(result.current.isMinimapOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.applyCanvasBackgroundColor('#abc');
|
||||
});
|
||||
@@ -137,7 +140,7 @@ describe('useImageCanvasEditorChrome', () => {
|
||||
expect(result.current.activeSidebarPanel).toBe('layers');
|
||||
expect(result.current.isZoomMenuOpen).toBe(true);
|
||||
expect(result.current.isBackgroundSettingsOpen).toBe(true);
|
||||
expect(result.current.isMinimapOpen).toBe(false);
|
||||
expect(result.current.isMinimapOpen).toBe(true);
|
||||
expect(result.current.activeTool).toBe('hand');
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasTool,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import type { CanvasTool, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||
|
||||
type UseImageCanvasEditorChromeOptions = {
|
||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
@@ -33,12 +30,12 @@ export function useImageCanvasEditorChrome({
|
||||
null,
|
||||
);
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
useState<SidebarPanel | null>(null);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||
const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false);
|
||||
const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] =
|
||||
useState(false);
|
||||
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
|
||||
const [isMinimapOpen, setIsMinimapOpen] = useState(false);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createEditorAgentConversation,
|
||||
deleteEditorAgentConversation,
|
||||
getEditorAgentConversation,
|
||||
listEditorAgentConversations,
|
||||
streamEditorAgentMessage,
|
||||
} from './editorAgentClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
const fetchWithApiAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
fetchWithApiAuth: fetchWithApiAuthMock,
|
||||
}));
|
||||
|
||||
function createSseResponse(payload: string) {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('editorAgentClient', () => {
|
||||
afterEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
fetchWithApiAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('uses the planned editor agent conversation CRUD routes', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
conversations: [
|
||||
{
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-2',
|
||||
projectId: 'project-1',
|
||||
title: '新对话',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:01:00.000Z',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:02:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await listEditorAgentConversations('project-1');
|
||||
await createEditorAgentConversation('project-1', { title: '新对话' });
|
||||
await getEditorAgentConversation('conversation-1');
|
||||
await deleteEditorAgentConversation('conversation-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/editor/projects/project-1/agent-conversations',
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话列表失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/projects/project-1/agent-conversations',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: '新对话' }),
|
||||
}),
|
||||
'创建画布 Agent 会话失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/editor/agent-conversations/conversation-1',
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话失败',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/api/editor/agent-conversations/conversation-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除画布 Agent 会话失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('posts editor agent message streams and emits typed SSE events', async () => {
|
||||
fetchWithApiAuthMock.mockResolvedValueOnce(
|
||||
createSseResponse(
|
||||
[
|
||||
'event: stage',
|
||||
'data: {"conversationId":"conversation-1","stage":"thinking"}',
|
||||
'',
|
||||
'event: message_delta',
|
||||
'data: {"conversationId":"conversation-1","messageId":"assistant-1","role":"assistant","kind":"chat","textDelta":"我先看一下"}',
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"conversationId":"conversation-1","title":"生成角色"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
const events: string[] = [];
|
||||
|
||||
await streamEditorAgentMessage(
|
||||
'conversation-1',
|
||||
{
|
||||
clientMessageId: 'client-message-1',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
},
|
||||
{
|
||||
onEvent: (event) => events.push(event.event),
|
||||
},
|
||||
);
|
||||
|
||||
expect(events).toEqual(['stage', 'message_delta', 'done']);
|
||||
expect(fetchWithApiAuthMock).toHaveBeenCalledWith(
|
||||
'/api/editor/agent-conversations/conversation-1/messages/stream',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientMessageId: 'client-message-1',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
authImpact: 'local',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
CreateEditorAgentConversationRequest,
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentConversationListResponse,
|
||||
EditorAgentConversationResponse,
|
||||
EditorAgentConversationSummary,
|
||||
EditorAgentSseEvent,
|
||||
StreamEditorAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import {
|
||||
appendApiErrorRequestId,
|
||||
parseApiErrorMessage,
|
||||
} from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
import { readEditorAgentSseEvents } from './editorAgentSse';
|
||||
|
||||
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
|
||||
const EDITOR_AGENT_STREAM_TIMEOUT_MS = 1_200_000;
|
||||
|
||||
export type StreamEditorAgentMessageOptions = {
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: EditorAgentSseEvent) => void;
|
||||
};
|
||||
|
||||
type DeleteEditorAgentConversationResponse = {
|
||||
deletedConversationId: string;
|
||||
conversation: EditorAgentConversationSummary;
|
||||
};
|
||||
|
||||
function jsonRequest(method: 'POST', body: Record<string, unknown>) {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function projectAgentConversationsPath(projectId: string) {
|
||||
return `${EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE}/${encodeURIComponent(
|
||||
projectId,
|
||||
)}/agent-conversations`;
|
||||
}
|
||||
|
||||
function agentConversationPath(conversationId: string) {
|
||||
return `${EDITOR_AGENT_CONVERSATION_API_BASE}/${encodeURIComponent(
|
||||
conversationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function listEditorAgentConversations(
|
||||
projectId: string,
|
||||
): Promise<EditorAgentConversationSummary[]> {
|
||||
const response = await requestJson<EditorAgentConversationListResponse>(
|
||||
projectAgentConversationsPath(projectId),
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话列表失败',
|
||||
);
|
||||
return response.conversations;
|
||||
}
|
||||
|
||||
export async function createEditorAgentConversation(
|
||||
projectId: string,
|
||||
input: CreateEditorAgentConversationRequest = {},
|
||||
): Promise<EditorAgentConversationDetail> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.title !== undefined) {
|
||||
body.title = input.title;
|
||||
}
|
||||
const response = await requestJson<EditorAgentConversationResponse>(
|
||||
projectAgentConversationsPath(projectId),
|
||||
jsonRequest('POST', body),
|
||||
'创建画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function getEditorAgentConversation(
|
||||
conversationId: string,
|
||||
): Promise<EditorAgentConversationDetail> {
|
||||
const response = await requestJson<EditorAgentConversationResponse>(
|
||||
agentConversationPath(conversationId),
|
||||
{ method: 'GET' },
|
||||
'读取画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function deleteEditorAgentConversation(
|
||||
conversationId: string,
|
||||
): Promise<EditorAgentConversationSummary> {
|
||||
const response = await requestJson<DeleteEditorAgentConversationResponse>(
|
||||
agentConversationPath(conversationId),
|
||||
{ method: 'DELETE' },
|
||||
'删除画布 Agent 会话失败',
|
||||
);
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function streamEditorAgentMessage(
|
||||
conversationId: string,
|
||||
payload: StreamEditorAgentMessageRequest,
|
||||
options: StreamEditorAgentMessageOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${agentConversationPath(conversationId)}/messages/stream`,
|
||||
{
|
||||
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
|
||||
signal: options.signal,
|
||||
},
|
||||
{
|
||||
timeoutMs: EDITOR_AGENT_STREAM_TIMEOUT_MS,
|
||||
authImpact: 'local',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(
|
||||
appendApiErrorRequestId(
|
||||
parseApiErrorMessage(responseText, '发送画布 Agent 消息失败'),
|
||||
response.headers.get('x-request-id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
await readEditorAgentSseEvents(response, (event) => options.onEvent?.(event));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { readEditorAgentSseEvents } from './editorAgentSse';
|
||||
|
||||
function createSseResponse(chunks: string[]) {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('readEditorAgentSseEvents parses stage, generation result and done events', async () => {
|
||||
const onEvent = vi.fn();
|
||||
await readEditorAgentSseEvents(
|
||||
createSseResponse([
|
||||
'event: stage\r\ndata: {"conversationId":"conversation-1","stage":"generating"}\r\n\r\n',
|
||||
'event: generation_result\r\ndata: {"conversationId":"conversation-1","messageId":"assistant-1","toolCallId":"tool-1","toolName":"generate_image","model":"gpt-image-2","images":[{"resourceId":"resource-1","imageSrc":"/generated/1.png","thumbnailSrc":null,"width":512,"height":512}]}\r\n\r\n',
|
||||
'event: done\r\ndata: {"conversationId":"conversation-1","title":"像素角色"}\r\n\r\n',
|
||||
]),
|
||||
onEvent,
|
||||
);
|
||||
|
||||
expect(onEvent).toHaveBeenNthCalledWith(1, {
|
||||
event: 'stage',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
stage: 'generating',
|
||||
},
|
||||
});
|
||||
expect(onEvent).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
event: 'generation_result',
|
||||
data: expect.objectContaining({
|
||||
toolCallId: 'tool-1',
|
||||
images: [
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
imageSrc: '/generated/1.png',
|
||||
thumbnailSrc: null,
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onEvent).toHaveBeenNthCalledWith(3, {
|
||||
event: 'done',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
title: '像素角色',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('readEditorAgentSseEvents accepts fallback event name from message payload', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
await readEditorAgentSseEvents(
|
||||
createSseResponse([
|
||||
'data: {"event":"error","data":{"conversationId":"conversation-1","code":"INSUFFICIENT_BALANCE","message":"泥点不足","recoverable":true}}\n\n',
|
||||
]),
|
||||
onEvent,
|
||||
);
|
||||
|
||||
expect(onEvent).toHaveBeenCalledWith({
|
||||
event: 'error',
|
||||
data: {
|
||||
conversationId: 'conversation-1',
|
||||
code: 'INSUFFICIENT_BALANCE',
|
||||
message: '泥点不足',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { EditorAgentSseEvent } from '../../../packages/shared/src/contracts/editorAgent';
|
||||
import { readSseJsonStream } from '../sseStream';
|
||||
|
||||
const EDITOR_AGENT_SSE_EVENT_NAMES = new Set<EditorAgentSseEvent['event']>([
|
||||
'stage',
|
||||
'message_delta',
|
||||
'tool_started',
|
||||
'tool_completed',
|
||||
'generation_result',
|
||||
'error',
|
||||
'done',
|
||||
]);
|
||||
|
||||
function isEditorAgentSseEventName(
|
||||
value: unknown,
|
||||
): value is EditorAgentSseEvent['event'] {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
EDITOR_AGENT_SSE_EVENT_NAMES.has(value as EditorAgentSseEvent['event'])
|
||||
);
|
||||
}
|
||||
|
||||
function readEventNameFromPayload(parsed: Record<string, unknown>) {
|
||||
return isEditorAgentSseEventName(parsed.event) ? parsed.event : null;
|
||||
}
|
||||
|
||||
function readEventDataFromPayload(parsed: Record<string, unknown>) {
|
||||
const data = parsed.data;
|
||||
return typeof data === 'object' && data !== null
|
||||
? (data as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function normalizeEditorAgentSseEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): EditorAgentSseEvent | null {
|
||||
const payloadEventName = readEventNameFromPayload(parsed);
|
||||
if (payloadEventName) {
|
||||
const payloadData = readEventDataFromPayload(parsed);
|
||||
if (!payloadData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
event: payloadEventName,
|
||||
data: payloadData,
|
||||
} as unknown as EditorAgentSseEvent;
|
||||
}
|
||||
|
||||
if (!isEditorAgentSseEventName(eventName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: parsed,
|
||||
} as unknown as EditorAgentSseEvent;
|
||||
}
|
||||
|
||||
export async function readEditorAgentSseEvents(
|
||||
response: Response,
|
||||
onEvent: (event: EditorAgentSseEvent) => void,
|
||||
) {
|
||||
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||
const event = normalizeEditorAgentSseEvent(eventName, parsed);
|
||||
if (event) {
|
||||
onEvent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user