补齐生成中间产物持久化与拆图提示

图标和UI图集拆分失败时保留主图集并在内联、队列及刷新路径提示
角色、图片修改、图标UI和角色动作链路把可恢复中间产物写入项目与素材库
外部生成摘要新增独立告警字段并清理成功任务的历史错误
同步共享契约、SpacetimeDB迁移与绑定、OpenAPI、设计文档和回归测试
This commit is contained in:
2026-07-13 21:41:46 +08:00
parent f1639de5fe
commit 4e7bcb8f24
29 changed files with 945 additions and 216 deletions
@@ -262,6 +262,7 @@ describe('ImageCanvasEditorView generation integration', () => {
phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。',
progress: overrides.progress ?? 40,
error: overrides.error ?? null,
warning: overrides.warning ?? null,
priceMudPoints: overrides.priceMudPoints ?? 3,
refundLedgerId: overrides.refundLedgerId ?? null,
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
@@ -2895,6 +2896,7 @@ describe('ImageCanvasEditorView generation integration', () => {
status: 'completed',
progress: 100,
phaseDetail: '生成已完成。',
warning: '连通域数量不足',
completedAt: '2026-06-21T00:01:00.000Z',
updatedAt: '2026-06-21T00:01:00.000Z',
updatedAtMicros: 2,
@@ -2934,6 +2936,9 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId);
});
expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy();
expect((await screen.findByRole('alert')).textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
await waitFor(() => {
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
});
@@ -1098,18 +1098,6 @@ 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) =>
@@ -1255,6 +1243,30 @@ export function ImageCanvasEditorView({
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const showGenerationWarning = generationSurface.showGenerationWarning;
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
return;
}
const warning = tasks.find((task) => task.warning?.trim())?.warning?.trim();
if (warning) {
showGenerationWarning(
`${warning}`,
);
}
refreshEditorWalletBalance();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[
applyGeneratedProjectSnapshot,
projectId,
refreshEditorWalletBalance,
showGenerationWarning,
],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
const toggleAgentConversation = useCallback(() => {
@@ -58,6 +58,7 @@ function createExternalTask(
phaseDetail: overrides.phaseDetail ?? '正在生成第 2/4 段。',
progress: overrides.progress ?? 35,
error: overrides.error ?? null,
warning: overrides.warning ?? null,
priceMudPoints: overrides.priceMudPoints ?? 1,
refundLedgerId: overrides.refundLedgerId ?? null,
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
@@ -236,6 +237,7 @@ describe('ImageCanvasTaskSidebarView', () => {
updatedAt: completedAt,
progress: 100,
phaseDetail: '生成已完成。',
warning: '连通域数量不足',
}),
createExternalTask({
jobId: 'done-other-project',
@@ -273,6 +275,7 @@ describe('ImageCanvasTaskSidebarView', () => {
fireEvent.click(screen.getByRole('tab', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByText('连通域数量不足')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
});
@@ -129,6 +129,9 @@ function externalTaskProgressDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (status === 'done') {
return task.warning?.trim() || null;
}
if (!isActiveTaskStatus(status)) {
return null;
}
@@ -166,6 +166,7 @@ function createQueueState(
phaseDetail: string;
progress: number;
error: string | null;
warning: string | null;
updatedAtMicros: number;
}> = {},
) {
@@ -223,6 +224,9 @@ function SubmissionWorkflowHarness({
useState<CharacterAnimationPanelState | null>(
initialCharacterAnimationPanel,
);
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersMockRef = useRef(vi.fn());
const layerCounterRef = useRef(0);
@@ -284,6 +288,7 @@ function SubmissionWorkflowHarness({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning: setGenerationWarning,
});
return (
@@ -328,6 +333,7 @@ function SubmissionWorkflowHarness({
<span data-testid="remembered-model">
{rememberedImageModelRef.current ?? '-'}
</span>
<span data-testid="generation-warning">{generationWarning ?? '-'}</span>
<button
type="button"
onClick={() => {
@@ -1694,6 +1700,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
applyProjectSnapshot,
refreshTaskList,
refreshWalletBalance,
undefined,
'dialog-background-removal',
);
await Promise.resolve();
@@ -1717,6 +1724,35 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
}
});
it('reports a non-blocking warning after a queued generation completes', async () => {
const onGenerationWarning = vi.fn();
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-1',
title: '队列图集项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
});
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning: '连通域数量不足',
}),
},
'editor-project-1',
vi.fn(),
vi.fn(),
vi.fn(),
onGenerationWarning,
);
expect(onGenerationWarning).toHaveBeenCalledWith(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
vi.useFakeTimers();
try {
@@ -2144,6 +2180,9 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
@@ -2482,6 +2521,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
@@ -2514,6 +2554,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
'generated-character-drafts/editor/generation-references/reference.png',
promptText: '跑步',
model: 'seedance2.0-fast',
assetFolderId: 'project',
}),
);
});
@@ -293,6 +293,7 @@ type GenerationSubmissionWorkflowOptions = {
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages(
@@ -428,6 +429,17 @@ function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
callback?.();
}
function notifyEditorGenerationWarning(
warning: string | null | undefined,
callback?: (message: string) => void,
) {
const reason = warning?.trim();
if (!reason) {
return;
}
callback?.(`图集已生成,但自动拆分未完成:${reason}`);
}
async function runEditorGenerationWithWalletRefresh<T>(
operation: Promise<T>,
onWalletBalanceMayHaveChanged?: () => void,
@@ -472,6 +484,7 @@ export async function applyQueuedEditorGenerationProject(
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
onQueuedGenerationTask?: () => void,
onWalletBalanceMayHaveChanged?: () => void,
onGenerationWarning?: (message: string) => void,
completionDialogId?: string | null,
transformProjectSnapshot?: (
project: EditorProjectSnapshot,
@@ -491,6 +504,7 @@ export async function applyQueuedEditorGenerationProject(
if (!terminalState) {
return true;
}
notifyEditorGenerationWarning(terminalState.warning, onGenerationWarning);
if (projectId && applyProjectSnapshot) {
const applyLoadedProject = (project: EditorProjectSnapshot) => {
applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project);
@@ -576,6 +590,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
@@ -1075,6 +1090,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}),
onWalletBalanceMayHaveChanged,
);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1082,6 +1101,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
@@ -1120,6 +1140,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
fitLayers,
projectId,
assetFolderId,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
],
);
@@ -1203,6 +1224,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
);
rememberImageModel(submissionPlan.rememberImageModel);
notifyEditorGenerationWarning(
generated.sliceWarning?.reason,
onGenerationWarning,
);
if (
await applyQueuedEditorGenerationProject(
generated,
@@ -1210,6 +1235,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
)
) {
return;
@@ -1243,6 +1269,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
onGenerationWarning,
onWalletBalanceMayHaveChanged,
],
);
@@ -2101,6 +2128,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
projectId,
generationInputs,
sourceResourceId: characterAnimationSourceLayer.resourceId,
assetFolderId,
...(canvasDialog && projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
@@ -343,7 +343,7 @@ export function useImageCanvasGenerationSurface({
const generationComposerNode = (
<>
{generationWorkflow.referencePickWarning ? (
{generationWorkflow.generationWarning ? (
<PlatformRuntimeStatusToast
tone="warning"
surface="solid"
@@ -351,12 +351,12 @@ export function useImageCanvasGenerationSurface({
className="image-canvas-editor__reference-pick-toast"
role="alert"
>
<span>{generationWorkflow.referencePickWarning}</span>
<span>{generationWorkflow.generationWarning}</span>
<button
type="button"
className="image-canvas-editor__reference-pick-toast-close"
aria-label="关闭提示"
onClick={generationWorkflow.clearReferencePickWarning}
onClick={generationWorkflow.clearGenerationWarning}
>
<X aria-hidden="true" size={14} strokeWidth={2.4} />
</button>
@@ -247,7 +247,7 @@ function GenerationWorkflowHarness({
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
</span>
<span data-testid="reference-pick-warning">
{workflow.referencePickWarning ?? '-'}
{workflow.generationWarning ?? '-'}
</span>
<span data-testid="quick-edit">
{workflow.quickEditPanel
@@ -654,9 +654,9 @@ export function useImageCanvasGenerationWorkflow({
] = useState(false);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [referencePickWarning, setReferencePickWarning] = useState<
string | null
>(null);
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const [cropExpandPanel, setCropExpandPanel] =
useState<CropExpandPanelState | null>(null);
const [uiAssetExtractionState, setUiAssetExtractionState] =
@@ -1618,6 +1618,7 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot,
refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
setGenerationWarning,
backgroundRemovalDialogId,
applyBackgroundRemovalProjectSnapshot
? (project) =>
@@ -1757,10 +1758,10 @@ export function useImageCanvasGenerationWorkflow({
const pickCharacterSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isCharacterSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_CHARACTER_SPEC_WARNING);
setGenerationWarning(INVALID_CHARACTER_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
setGenerationWarning(null);
setGenerateDialog((currentDialog) => {
const nextDialog = assignCharacterSpecReference(currentDialog, layer);
if (
@@ -1817,10 +1818,10 @@ export function useImageCanvasGenerationWorkflow({
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
setGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
setGenerationWarning(null);
const nextDialog = assignIconSpecReference(generateDialog, layer);
if (nextDialog?.mode === 'icon' && nextDialog.iconSpecReference) {
lastIconSpecReferenceRef.current = nextDialog.iconSpecReference;
@@ -1839,10 +1840,10 @@ export function useImageCanvasGenerationWorkflow({
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
if (!isIconSpecReferenceLayer(layer)) {
setReferencePickWarning(INVALID_ICON_SPEC_WARNING);
setGenerationWarning(INVALID_ICON_SPEC_WARNING);
return;
}
setReferencePickWarning(null);
setGenerationWarning(null);
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
if (
nextDialog?.mode === 'ui-design' &&
@@ -1923,6 +1924,7 @@ export function useImageCanvasGenerationWorkflow({
applyProjectSnapshot,
onQueuedGenerationTask: refreshTaskListForQueuedGeneration,
onWalletBalanceMayHaveChanged,
onGenerationWarning: setGenerationWarning,
});
const setEffectiveCharacterAnimationPanel: Dispatch<
@@ -2488,8 +2490,9 @@ export function useImageCanvasGenerationWorkflow({
setIsPublicationReferenceMenuOpen,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
referencePickWarning,
clearReferencePickWarning: () => setReferencePickWarning(null),
generationWarning,
showGenerationWarning: setGenerationWarning,
clearGenerationWarning: () => setGenerationWarning(null),
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
@@ -2579,7 +2582,7 @@ export function useImageCanvasGenerationWorkflow({
isPublicationReferenceMenuOpen,
isSpecMenuOpen,
isPickingPublicationReferenceFromCanvas,
referencePickWarning,
generationWarning,
openBackgroundMusicGenerationDialog,
openCharacterAnimationPanel,
openCharacterGenerationDialog,