修复画布生成刷新后完成同步

任务列表识别当前画布项目的未确认完成任务并发出完成通知。

编辑器收到任务完成通知后重新加载项目快照并刷新素材库。

补充刷新后任务已完成和轮询完成的回归测试。
This commit is contained in:
2026-07-07 19:45:00 +08:00
parent 00db7d39ea
commit 754d364227
5 changed files with 382 additions and 17 deletions
@@ -11,6 +11,7 @@ import {
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import {
ApiClientError,
defaultEditorProjectLayers,
@@ -244,6 +245,34 @@ describe('ImageCanvasEditorView generation integration', () => {
updatedAt: '2026-06-19T00:00:00.000Z',
});
const createExternalGenerationTask = (
overrides: Partial<ExternalGenerationTaskRecord> = {},
): ExternalGenerationTaskRecord => {
const now = new Date().toISOString();
return {
jobId: overrides.jobId ?? 'task-editor-character-refresh',
jobKind: overrides.jobKind ?? 'editor_image_generation',
sourceModule: overrides.sourceModule ?? 'editor-canvas',
sourceEntityId:
overrides.sourceEntityId ?? 'editor-project-refresh-completion',
requestLabel: overrides.requestLabel ?? '图片画布生成角色形象',
requestPrompt: overrides.requestPrompt ?? '刷新后角色',
status: overrides.status ?? 'running',
phaseLabel: overrides.phaseLabel ?? '生成图片',
phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。',
progress: overrides.progress ?? 40,
error: overrides.error ?? null,
priceMudPoints: overrides.priceMudPoints ?? 3,
refundLedgerId: overrides.refundLedgerId ?? null,
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
createdAt: overrides.createdAt ?? now,
startedAt: overrides.startedAt ?? now,
completedAt: overrides.completedAt ?? null,
updatedAt: overrides.updatedAt ?? now,
updatedAtMicros: overrides.updatedAtMicros ?? 1,
};
};
const createGeneratedImageResponse = (
input: {
projectId?: string | null;
@@ -552,7 +581,9 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(generatedImage.closest('button')!);
expect(await screen.findByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(
await screen.findByRole('dialog', { name: '生成图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
).toBe('元数据恢复生成器');
@@ -2719,6 +2750,184 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
});
it('reloads project and asset snapshots when a restored queued generation completed after refresh', async () => {
window.history.replaceState(null, '', '/editor/canvas');
const projectId = 'editor-project-refresh-completion';
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId,
title: '刷新恢复画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-refresh-character',
resourceId: 'generation-dialog:generation-dialog-refresh-character',
dialog: {
id: 'generation-dialog-refresh-character',
mode: 'character',
prompt: '刷新后角色',
status: 'generating',
composerOpen: false,
imageModel: 'gpt-image-2',
placeholder: {
x: 240,
y: 120,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
},
},
],
resources: [],
updatedAt: '2026-06-21T00:00:00.000Z',
});
const generatedResource = createGeneratedResource({
resourceId: 'resource-refresh-character-result',
projectId,
imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=',
width: 1024,
height: 1536,
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '刷新后角色' }],
references: [],
},
});
loadEditorProjectMock.mockResolvedValueOnce({
projectId,
title: '刷新恢复画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-refresh-character-result',
resourceId: 'resource-refresh-character-result',
title: '刷新后生成结果',
x: 240,
y: 120,
width: 1024,
height: 1536,
originalWidth: 1024,
originalHeight: 1536,
zIndex: 12,
sourceType: 'generated',
assetKind: 'character',
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-refresh-character',
resourceId: 'generation-dialog:generation-dialog-refresh-character',
dialog: {
id: 'generation-dialog-refresh-character',
mode: 'character',
prompt: '刷新后角色',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-refresh-character-result',
imageModel: 'gpt-image-2',
placeholder: {
x: 240,
y: 120,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
},
},
],
resources: [generatedResource],
updatedAt: '2026-06-21T00:01:00.000Z',
});
loadEditorAssetLibraryMock
.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [],
})
.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createGeneratedAsset({
assetId: 'asset-refresh-character-result',
folderId: 'project',
label: '刷新后生成结果素材',
imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=',
width: 1024,
height: 1536,
assetKind: 'character',
}),
],
});
const completedTask = createExternalGenerationTask({
jobId: 'task-refresh-character-result',
sourceEntityId: projectId,
requestPrompt: '刷新后角色',
status: 'completed',
progress: 100,
phaseDetail: '生成已完成。',
completedAt: '2026-06-21T00:01:00.000Z',
updatedAt: '2026-06-21T00:01:00.000Z',
updatedAtMicros: 2,
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const requestUrl =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(requestUrl, 'http://localhost');
if (url.pathname === '/api/runtime/external-generation/jobs') {
const statuses = url.searchParams.get('statuses') ?? '';
return new Response(
JSON.stringify({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 1,
updatedAtMicros: 2,
},
tasks: statuses.includes('completed') ? [completedTask] : [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
return new Response(null, { status: 404 });
});
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId);
});
expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy();
await waitFor(() => {
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
});
it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => {
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
@@ -3174,7 +3383,9 @@ describe('ImageCanvasEditorView generation integration', () => {
});
expect(
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole<HTMLButtonElement>('button', {
within(
screen.getByRole('dialog', { name: 'UI素材提取' }),
).getByRole<HTMLButtonElement>('button', {
name: '提取',
}).disabled,
).toBe(false);
@@ -3265,9 +3476,7 @@ describe('ImageCanvasEditorView generation integration', () => {
});
render(<ImageCanvasEditorView />);
const uiDesignImage = await screen.findByAltText(
'画布图片:背包UI设计图',
);
const uiDesignImage = await screen.findByAltText('画布图片:背包UI设计图');
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
button: 0,
pointerId: 256,
@@ -1052,6 +1052,18 @@ export function ImageCanvasEditorView({
},
[applyProjectSnapshot, refreshAssetLibrary],
);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
return;
}
refreshEditorWalletBalance();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[applyGeneratedProjectSnapshot, projectId, refreshEditorWalletBalance],
);
const handleEditorAgentGenerationResult = useCallback(
(event: EditorAgentGenerationResultEvent) => {
const hasGeneratedResource = event.images.some((image) =>
@@ -2033,6 +2045,7 @@ export function ImageCanvasEditorView({
onGenerationFrameContextMenu: handleGenerationFrameContextMenu,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
onEditorAgentGenerationResult: handleEditorAgentGenerationResult,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
@@ -125,6 +125,7 @@ export type ImageCanvasStageViewProps = {
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
onEditorAgentGenerationResult?: (
event: EditorAgentGenerationResultEvent,
) => void;
@@ -257,6 +258,7 @@ export function ImageCanvasStageView({
onGenerationFrameContextMenu,
onActivateGenerationDialog,
onFocusExternalTask,
onExternalTasksCompleted,
onEditorAgentGenerationResult,
onToggleTaskSidebar,
onToggleAgentConversation,
@@ -465,6 +467,7 @@ export function ImageCanvasStageView({
open={isTaskSidebarOpen}
onToggleOpen={onToggleTaskSidebar}
onFocusExternalTask={onFocusExternalTask}
onExternalTasksCompleted={onExternalTasksCompleted}
/>
{isAgentConversationEnabled ? (
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
@@ -239,9 +245,7 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(await screen.findByText('图片画布生成图片')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(
screen.getByText(/ 2\/4 · 1/u),
).toBeTruthy();
expect(screen.getByText(/ 2\/4 · 1/u)).toBeTruthy();
expect(screen.queryByText(/35%/u)).toBeNull();
expect(screen.getByText(/ 1/u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
@@ -356,6 +360,81 @@ describe('ImageCanvasTaskSidebarView', () => {
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(4);
});
it('notifies when a restored active external task completes during polling', async () => {
vi.useFakeTimers();
try {
const onExternalTasksCompleted = vi.fn();
let activeRequestCount = 0;
const runningTask = createExternalTask({
jobId: 'restored-character-task',
requestLabel: '角色图片',
status: 'running',
});
const completedTask = createExternalTask({
...runningTask,
status: 'completed',
progress: 100,
phaseDetail: '生成已完成。',
completedAt: new Date().toISOString(),
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
return Promise.resolve({
overview: {
pendingCount: activeRequestCount === 1 ? 1 : 0,
runningCount: activeRequestCount === 1 ? 1 : 0,
unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount === 1 ? [runningTask] : [],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount > 1 ? [completedTask] : [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTasksCompleted={onExternalTasksCompleted}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('角色图片')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTasksCompleted).toHaveBeenCalledWith([
expect.objectContaining({
jobId: 'restored-character-task',
status: 'completed',
}),
]);
expect(onExternalTasksCompleted).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();
@@ -47,6 +47,7 @@ type ImageCanvasTaskSidebarViewProps = {
open: boolean;
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => void;
};
function parseTaskTimeMs(value?: string | null) {
@@ -117,9 +118,9 @@ function externalTaskProgressDetail(
if (!isActiveTaskStatus(status)) {
return null;
}
const values = [
task.phaseDetail.trim(),
].filter((value): value is string => Boolean(value));
const values = [task.phaseDetail.trim()].filter((value): value is string =>
Boolean(value),
);
return values.length > 0 ? values.join(' · ') : null;
}
@@ -205,6 +206,10 @@ function isTerminalExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'completed' || task.status === 'failed';
}
function isCompletedExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'completed';
}
function isEditorCanvasTaskForProject(
task: ExternalGenerationTaskRecord,
projectId?: string | null,
@@ -256,6 +261,7 @@ export function ImageCanvasTaskSidebarView({
open,
onToggleOpen,
onFocusExternalTask,
onExternalTasksCompleted,
}: ImageCanvasTaskSidebarViewProps) {
const projectId = useImageCanvasContextStore((state) => state.projectId);
const normalizedProjectId = projectId?.trim() ?? '';
@@ -273,6 +279,8 @@ export function ImageCanvasTaskSidebarView({
? completedListState.limit
: COMPLETED_TASK_LIST_LIMIT;
const previousActiveItemCountRef = useRef(0);
const activeExternalTaskIdsRef = useRef<Set<string>>(new Set());
const completedRefreshNotifiedTaskIdsRef = useRef<Set<string>>(new Set());
const filterVisibleExternalTasks = useCallback(
(tasks: ExternalGenerationTaskRecord[]) =>
tasks.filter((task) =>
@@ -280,6 +288,39 @@ export function ImageCanvasTaskSidebarView({
),
[normalizedProjectId],
);
const notifyCompletedExternalTasks = useCallback(
(
tasks: ExternalGenerationTaskRecord[],
options: {
activeTaskIds?: ReadonlySet<string>;
unacknowledgedOnly?: boolean;
} = {},
) => {
const newlyCompletedTasks = tasks.filter((task) => {
if (
!isCompletedExternalTask(task) ||
completedRefreshNotifiedTaskIdsRef.current.has(task.jobId)
) {
return false;
}
if (options.activeTaskIds && !options.activeTaskIds.has(task.jobId)) {
return false;
}
if (options.unacknowledgedOnly && task.notificationAcknowledgedAt) {
return false;
}
return true;
});
if (!newlyCompletedTasks.length) {
return;
}
for (const task of newlyCompletedTasks) {
completedRefreshNotifiedTaskIdsRef.current.add(task.jobId);
}
onExternalTasksCompleted?.(newlyCompletedTasks);
},
[onExternalTasksCompleted],
);
useEffect(() => {
setCompletedListState((state) =>
@@ -287,6 +328,7 @@ export function ImageCanvasTaskSidebarView({
? state
: { limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId },
);
completedRefreshNotifiedTaskIdsRef.current = new Set();
}, [normalizedProjectId]);
useEffect(() => {
@@ -310,12 +352,18 @@ export function ImageCanvasTaskSidebarView({
if (disposed) {
return;
}
const visibleActiveTasks = filterVisibleExternalTasks(
activeResponse.tasks,
);
const visibleCompletedTasks = filterVisibleExternalTasks(
completedResponse.tasks,
);
notifyCompletedExternalTasks(visibleCompletedTasks, {
unacknowledgedOnly: true,
});
setExternalTasks(
trimStoredExternalTasks(
mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
),
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
completedListLimit,
),
);
@@ -329,7 +377,12 @@ export function ImageCanvasTaskSidebarView({
disposed = true;
controller.abort();
};
}, [completedListLimit, filterVisibleExternalTasks, refreshKey]);
}, [
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
refreshKey,
]);
const activeExternalTaskIds = useMemo(
() =>
@@ -340,6 +393,10 @@ export function ImageCanvasTaskSidebarView({
);
const activeExternalTaskKey = activeExternalTaskIds.join('|');
useEffect(() => {
activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds);
}, [activeExternalTaskKey, activeExternalTaskIds]);
useEffect(() => {
if (!activeExternalTaskIds.length) {
return undefined;
@@ -371,6 +428,9 @@ export function ImageCanvasTaskSidebarView({
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
);
notifyCompletedExternalTasks(refreshedTasks, {
activeTaskIds: activeExternalTaskIdsRef.current,
});
setExternalTasks((currentTasks) =>
trimStoredExternalTasks(
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
@@ -407,6 +467,7 @@ export function ImageCanvasTaskSidebarView({
activeExternalTaskKey,
completedListLimit,
filterVisibleExternalTasks,
notifyCompletedExternalTasks,
]);
const handleTaskListScroll = useCallback(