实现画布Agent侧边栏聊天

新增画布Agent会话契约、领域规则、SpacetimeDB元数据表和生成绑定。
新增api-server画布Agent会话CRUD、OSS消息文档读写、SSE消息流和生成工具编排。
新增前端右侧Agent对话面板、会话状态hook、SSE客户端、BFF客户端和相关测试。
调整画布侧栏、任务栏、小地图默认状态和面板互斥交互。
补齐画布Agent、OSS消息存储、SSE收口、后端契约和项目记忆文档。
This commit is contained in:
2026-07-04 12:03:58 +08:00
parent 4637a5e25a
commit 8dad52f040
75 changed files with 7836 additions and 285 deletions
@@ -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);
}
});
}