diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts index 7f63af339..08c12bb1c 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.test.ts @@ -320,7 +320,10 @@ describe('Idempotency-Key', () => { resolveModel3dRequestKey({ attemptNonce: null, endpoint: 'text-to-model', - body: { generation: { model: 'v3.1-20260211' } }, + body: { + generation: { model: 'v3.1-20260211', prompt: '木椅' }, + target: { kind: 'projectResource', projectId: 'project-1' }, + }, }), ).toMatch(/^[\x21-\x7e]{1,128}$/); }); @@ -329,12 +332,18 @@ describe('Idempotency-Key', () => { const left = resolveModel3dRequestKey({ attemptNonce: 'model3d-fixed', endpoint: 'text-to-model', - body: { generation: { texture: true, model: 'v3.1-20260211' } }, + body: { + generation: { texture: true, model: 'v3.1-20260211', prompt: '木椅' }, + target: { kind: 'projectResource', projectId: 'project-1' }, + }, }); const right = resolveModel3dRequestKey({ attemptNonce: 'model3d-fixed', endpoint: 'text-to-model', - body: { generation: { model: 'v3.1-20260211', texture: true } }, + body: { + generation: { model: 'v3.1-20260211', texture: true, prompt: '木椅' }, + target: { kind: 'projectResource', projectId: 'project-1' }, + }, }); expect(right).toBe(left); }); diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts index e2ee10ed7..f5d894301 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts +++ b/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts @@ -1,5 +1,10 @@ import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration'; -import type { Model3dGenerationSource } from '../../../../packages/shared/src/contracts/model3d'; +import type { + Model3dGenerationSource, + Model3dGenerationTarget, + Model3dImageToModelRequest, + Model3dTextToModelRequest, +} from '../../../../packages/shared/src/contracts/model3d'; import { isAbortError, isTimeoutError } from '../../../services/apiClient'; import type { EditorCanvasGenerationCompletionInput, @@ -41,7 +46,8 @@ export type Model3dSubmissionPlan = { attemptNonce: string; requestKey: string; title: string; - body: Record; + /** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */ + body: Model3dTextToModelRequest | Model3dImageToModelRequest; }; export function resolveModel3dResultTitle(dialog: GenerateDialogState): string { @@ -106,43 +112,28 @@ export function buildModel3dSubmissionPlan({ return { ok: false, message: paramsResult.reason }; } const params = paramsResult.params; - const prompt = dialog.prompt.trim(); - const source = - dialog.mode === 'model3d-image-to-model' - ? resolveModel3dImageSource(dialog) - : null; - if (dialog.mode === 'model3d-text-to-model' && !prompt) { - return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }; - } - if (dialog.mode === 'model3d-image-to-model' && !source) { - return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }; - } const endpoint = resolveModel3dEndpoint(dialog.mode); const attemptNonce = resolveModel3dAttemptNonce(dialog.model3dAttemptNonce); - const body: Record = { - ...(source ? { source } : {}), - // 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格, - // 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。 - generation: { - model: DEFAULT_MODEL3D_MODEL_VERSION, - ...(dialog.mode === 'model3d-text-to-model' ? { prompt } : {}), - texture: params.texture, - pbr: params.pbr, - ...(params.textureQuality - ? { textureQuality: params.textureQuality } - : {}), - geometryQuality: params.geometryQuality, - quad: params.quad, - smartLowPoly: params.smartLowPoly, - generateParts: params.generateParts, - }, - target: { - kind: 'projectResource', - projectId: trimmedProjectId, - ...(canvasCompletion ? { canvasCompletion } : {}), - }, + // 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格, + // 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。 + const generation = { + model: DEFAULT_MODEL3D_MODEL_VERSION, + texture: params.texture, + pbr: params.pbr, + ...(params.textureQuality ? { textureQuality: params.textureQuality } : {}), + geometryQuality: params.geometryQuality, + quad: params.quad, + smartLowPoly: params.smartLowPoly, + generateParts: params.generateParts, }; - return { + const target: Model3dGenerationTarget = { + kind: 'projectResource', + projectId: trimmedProjectId, + ...(canvasCompletion ? { canvasCompletion } : {}), + }; + const finish = ( + body: Model3dTextToModelRequest | Model3dImageToModelRequest, + ): { ok: true; plan: Model3dSubmissionPlan } => ({ ok: true, plan: { endpoint, @@ -155,7 +146,21 @@ export function buildModel3dSubmissionPlan({ title: resolveModel3dResultTitle(dialog), body, }, - }; + }); + + if (dialog.mode === 'model3d-image-to-model') { + const source = resolveModel3dImageSource(dialog); + if (!source) { + return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE }; + } + return finish({ source, generation, target }); + } + + const prompt = dialog.prompt.trim(); + if (!prompt) { + return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE }; + } + return finish({ generation: { ...generation, prompt }, target }); } /** @@ -174,7 +179,7 @@ export function resolveModel3dRequestKey({ }: { attemptNonce?: string | null; endpoint: Model3dPricingEndpoint; - body: Record; + body: Model3dTextToModelRequest | Model3dImageToModelRequest; }): string { const nonce = resolveModel3dAttemptNonce(attemptNonce); return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`; diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 9f463d45c..2e04bd5f6 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -10,6 +10,10 @@ import { } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/contracts/editorScene'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; +import type { + Model3dImageToModelRequest, + Model3dTextToModelRequest, +} from '../../../packages/shared/src/contracts/model3d'; import { requestJson } from '../apiClient'; import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; @@ -1742,7 +1746,8 @@ export async function submitModel3dGenerationRequest({ idempotencyKey, }: { endpoint: Model3dPricingEndpoint; - body: Record; + // 提交体直接用端点契约:形状不符(缺 target、generation 结构错)在编译期就会暴露。 + body: Model3dTextToModelRequest | Model3dImageToModelRequest; idempotencyKey: string; }) { return requestJson(