Files
Genarrative/src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts
T
k88936 946c101630 画布没接住任务状态时按失败收口
- src/components/image-editor/model3d-generation/useModel3dGenerationTask.ts:applyQueuedGeneration 返回 false 时不再静默复位成 idle(用户会以为没提交过、再点一次就是又一次已扣费的外部生成),改为写回失败态与明确文案
- src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts:新增 MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,与「服务端没返回任务状态」区分开
- src/components/image-editor/model3d-generation/useModel3dGenerationTask.test.tsx:原「回到空闲态」用例改为断言失败态
2026-09-24 17:32:13 +08:00

189 lines
7.1 KiB
TypeScript

import { useCallback } from 'react';
import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration';
import type {
EditorCanvasGenerationCompletionInput,
EditorProjectSnapshot,
} from '../../../services/image-editor/editorProjectClient';
import {
submitModel3dImageToModelRequest,
submitModel3dTextToModelRequest,
} from '../../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from '../ImageCanvasEditorTypes';
import { readEditorModel3dPricingConfig } from '../ImageCanvasGenerationModel';
import { isModel3dGenerationMode } from './Model3dGenerationFormModel';
import {
buildModel3dSubmissionPlan,
MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,
MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE,
model3dQueueStateFromResponse,
resolveModel3dGenerationErrorMessage,
resolveModel3dResultTitle,
} from './Model3dGenerationSubmission';
type ApplyQueuedGeneration = (
response: { queueState?: ExternalGenerationJobStatusRecord | null },
projectId: string | null | undefined,
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
onQueuedGenerationTask?: () => void,
onWalletBalanceMayHaveChanged?: () => void,
onGenerationWarning?: (message: string) => void,
completionDialogId?: string | null,
) => Promise<boolean>;
type UpdateCanvasGenerationDialogById = (
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => void;
/**
* 3D 生成的提交编排:只负责校验、入队、轮询与收口,请求构造在
* `Model3dGenerationSubmission`,画布与任务侧栏复用既有链路。
*/
export function useModel3dGenerationTask({
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
applyProjectSnapshot,
getGeneratingDialogPlaceholder,
applyQueuedGeneration,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
}: {
projectId?: string | null;
/** 当前素材夹:与其它画布生成工具一致,结果同时落素材库。 */
assetFolderId?: string | null;
updateCanvasGenerationDialogById: UpdateCanvasGenerationDialogById;
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => EditorCanvasGenerationCompletionInput['placeholder'] | null | undefined;
applyQueuedGeneration: ApplyQueuedGeneration;
onQueuedGenerationTask?: () => void;
onWalletBalanceMayHaveChanged?: () => void;
onGenerationWarning?: (message: string) => void;
}) {
const submitModel3dGeneration = useCallback(
async (dialog: GenerateDialogState) => {
const dialogId = dialog.id;
if (!dialogId || !isModel3dGenerationMode(dialog.mode)) {
return;
}
// 整个提交编排都落在 try 里:取占位框、算标题、构造提交计划同样可能抛
// (注入的回调、老快照缺字段),漏出去就成了一次没有用户提示的未捕获 rejection。
try {
const placeholder = getGeneratingDialogPlaceholder(dialog);
const canvasCompletion =
projectId && placeholder
? {
dialogId,
title: resolveModel3dResultTitle(dialog),
placeholder,
}
: null;
const planResult = buildModel3dSubmissionPlan({
dialog,
projectId,
canvasCompletion,
assetFolderId,
assetLabel: resolveModel3dResultTitle(dialog),
pricing: readEditorModel3dPricingConfig(),
});
if (!planResult.ok) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'failed',
composerOpen: true,
errorMessage: planResult.message,
}));
return;
}
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
// 兜底铸的代次要写回状态:否则老快照每次提交都换幂等键,去重保护失效。
model3dAttemptNonce: planResult.plan.attemptNonce,
status: 'generating',
composerOpen: false,
errorMessage: undefined,
}));
const plan = planResult.plan;
// 判别联合在这里收窄:文生与图生各走自己的客户端函数,端点与提交体的配对由类型保证。
const response =
plan.endpoint === 'text-to-model'
? await submitModel3dTextToModelRequest({
body: plan.body,
idempotencyKey: plan.requestKey,
})
: await submitModel3dImageToModelRequest({
body: plan.body,
idempotencyKey: plan.requestKey,
});
const queueState = model3dQueueStateFromResponse(response);
if (!queueState) {
// 2xx 但没有任务状态(空 body 或缺 status 的 payload)不能当成功静默复位:
// 用户既看不到任务也没有任何提示,服务端细节被丢掉。
// 服务端在这条路上也可能已经扣过费,钱包余额同样要按「可能变了」刷新。
onWalletBalanceMayHaveChanged?.();
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'failed',
composerOpen: true,
errorMessage: MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE,
}));
return;
}
if (
await applyQueuedGeneration(
{ queueState },
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning,
dialogId,
)
) {
return;
}
// 画布没接住这份任务状态时不能静默复位成 idle:用户会以为没提交过,
// 再点一次就是又一次外部生成(已经扣过费)。按失败收口,让用户先去看结果。
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'failed',
composerOpen: true,
errorMessage: MODEL3D_QUEUE_HANDOFF_FAILED_MESSAGE,
}));
} catch (error) {
// 超时等失败也可能意味着服务端已经扣费(与其它生成链路的
// runEditorGenerationWithWalletRefresh 同一口径),失败路径也要刷新钱包。
onWalletBalanceMayHaveChanged?.();
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'failed',
composerOpen: true,
errorMessage: resolveModel3dGenerationErrorMessage(error),
}));
}
},
[
applyProjectSnapshot,
applyQueuedGeneration,
assetFolderId,
getGeneratingDialogPlaceholder,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
projectId,
updateCanvasGenerationDialogById,
],
);
return { submitModel3dGeneration };
}