From 42c3186e1384f032d6a6dfb5c4a650035f81dac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 14:55:06 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E7=94=9F=E6=88=90=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E6=A8=A1=E5=9E=8B=E7=89=88=E6=9C=AC=E9=80=89?= =?UTF-8?q?=E6=8B=A9=EF=BC=8C=E5=8F=82=E6=95=B0=E6=8C=89=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model3dGenerationValidation.ts(新):前端唯一一份参数校验,逐条复刻 platform-tripo 的 validate_generation_options 与 api-server 的 PricingParamView::validate(模型族能力、分件三个前置条件、face_limit_bounds、平台必显式项),并给出按契约联合类型做键的 MODEL3D_MODEL_TABLE 与禁用原因文案 - Model3dGenerationFormModel:模型版本改为用户可选,可选集只取当前端点有底价的版本;报价与请求体读同一个 resolveModel3dModelVersion;换版本或切档位时按新能力收敛档位与开关并开新一代次;v2.5 的贴图档位不带 textureQuality - Model3dGenerationForm:新增模型版本选择器(照图片面板的浮层选项形态),档位与开关按模型能力禁用并给出 title 原因 - Model3dGenerationModal:定价只读一次,模型选项与报价共用同一份快照 - Model3dGenerationSubmission / useModel3dGenerationTask:提交计划改成 endpoint 判别联合,两个端点各一个收口函数 - editorProjectClient:删掉「endpoint + 联合提交体」的单函数,改为 submitModel3dTextToModelRequest / submitModel3dImageToModelRequest 一个端点一个函数;basePrices 两层键改成契约枚举 - ImageCanvasOptionChoice.tsx(新):把图片面板的浮层选项行抽成共享组件,3D 面板复用 - 测试:新增 Model3dGenerationValidation.test.ts(12 例),FormModel / Submission / Modal / useModel3dGenerationTask 补组合表与菜单用例,共 75 例通过 - 文档:实施计划与里程碑追加本轮口径与证据,decision-log 与 pitfalls 各记两条 --- ...€�实施计划】Tripo生成前端入口-2026-09-21.md | 27 +- .../【里程碑】Tripo生成前端入口-2026-09-21.md | 4 +- .../shared-memory/decision-log.md | 22 ++ docs/project-memory/shared-memory/pitfalls.md | 32 ++ .../image-editor/ImageCanvasEditorTypes.ts | 6 + .../ImageCanvasGenerationImageOptionsView.tsx | 41 +-- .../image-editor/ImageCanvasOptionChoice.tsx | 35 ++ .../Model3dGenerationForm.tsx | 148 +++++++- .../Model3dGenerationFormModel.test.ts | 193 ++++++++++ .../Model3dGenerationFormModel.ts | 212 +++++++++-- .../Model3dGenerationModal.test.tsx | 121 +++++++ .../Model3dGenerationModal.tsx | 14 +- .../Model3dGenerationSubmission.test.ts | 58 +++ .../Model3dGenerationSubmission.ts | 71 +++- .../Model3dGenerationValidation.test.ts | 283 +++++++++++++++ .../Model3dGenerationValidation.ts | 332 ++++++++++++++++++ .../useModel3dGenerationTask.test.tsx | 77 +++- .../useModel3dGenerationTask.ts | 22 +- .../image-editor/editorProjectClient.ts | 69 +++- 19 files changed, 1619 insertions(+), 148 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasOptionChoice.tsx create mode 100644 src/components/image-editor/model3d-generation/Model3dGenerationValidation.test.ts create mode 100644 src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts diff --git a/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md index a805eb7e8..22f9c7f7c 100644 --- a/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md +++ b/docs/project-memory/plans/【实施计划】Tripo生成前端入口-2026-09-21.md @@ -15,7 +15,10 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2 1. 宿主是画布生成链路,不新建独立生成页 / 结果页 / runtime / 作品架;3D 是资产型产出。 2. 入口形态照 `music` / `spec`:工具栏一项入口 → 浮动子选项 → 各自一个提交面板。子项文案沿用后端 `phaseLabel`:「文生 3D 模型」「图生 3D 模型」。 3. 价格**只**来自 `GET /api/editor/generation-pricing` 的 `model3d` 段(前端已有 `loadEditorGenerationPricing` 与运行时缓存)。段缺失即两个子面板都不可提交且不发请求;前端不写任何 3D 兜底数值,也不把缺价当 0。 -4. 参数永远全量非空提交:`model`、`texture`、`pbr`、`geometryQuality`、`quad`、`smartLowPoly`、`generateParts` 一律显式给出,不依赖后端默认值(provider 隐式默认会改变价格)。唯一例外是 `textureQuality`:后端 `api-server::tripo3d::validation` 显式拒绝 `texture=false` 时出现贴图档位,因此纯几何档位必须**不带**该字段,而不是给 `null`。 +4. 参数永远全量非空提交:`model`、`texture`、`pbr`、`geometryQuality`、`quad`、`smartLowPoly`、`generateParts` 一律显式给出,不依赖后端默认值(provider 隐式默认会改变价格)。两个「不带该字段」的例外都在 `textureQuality` 上:`texture=false` 时后端显式拒绝贴图档位;v2.5 被 provider 预检拒绝任何 `texture_quality`(缺省按 standard 计价,与面板报价同档),因此这两种情况必须**不带**该字段,而不是给 `null`。 +4.1 模型版本(2026-09-24 追加)由用户选择,缺省 `v3.1-20260211`。可选集不写死在前端常量里,而是取**当前端点有底价**的版本(定价段的键),与「缺价不可提交」同一条失败关闭口径;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`,面板显示的价格与发出的 `generation.model` 不可能分叉。 +4.2 能力组合(2026-09-24 追加)在前端有一份与 provider 对齐的校验:`model3d-generation/Model3dGenerationValidation.ts` 逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`(模型族能力、分件的前置条件、面数上限边界、平台必显式项)。抄的是规则不是数值,规则冲突以 provider 为准;面板的禁用态与禁用原因都读这一份,`Model3dGenerationFormModel.test.ts` / `Model3dGenerationValidation.test.ts` 用组合表把两边钉在一起。之所以必须抄:契约与公开读模型只暴露价格,不暴露「哪些版本支持哪些参数」,否则用户会选到一个提交后必然被 provider 拒的组合。 +4.3 客户端提交按端点拆成两个函数(`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest`),提交计划是 `endpoint` 判别联合。原先「endpoint + 联合提交体」的单函数允许把图生请求体发到文生地址且照样编译通过,配错端点只能等远端按字段报错。 5. 落点照现有画布生成工具的做法:**同时**发 `projectId`(可空,缺失时不发这个字段)与当前素材夹 `assetFolderId` + `assetLabel`,画布占位框 `canvasCompletion: { dialogId, title, placeholder }` 在 `projectId && canvasCompletionPlaceholder` 时带上;发送门禁是「两个落点至少给一个」,不再要求先保存工程。结果因此同时落画布与素材库,没有工程时只落素材库。 6. 图生输入照现有参考图来源菜单:从画布中选择 / 上传图片 / 从项目素材中选择,最终收敛成契约允许的 `{ kind: "resource", resourceId }` 或 `{ kind: "asset", assetId }`;只允许一张,不接 multiview。 7. `Idempotency-Key` 由前端铸造并绑定当次请求,用户主动重试铸造新键、不复用旧键。落地形态是「尝试代次 + 请求内容指纹」(`Model3dGenerationSubmission.resolveModel3dRequestKey`):同一份内容重复提交(双击)仍命中同一个 operation,内容一变(含换参考图、换上传图、换素材库图片)自动换键;重试换代次即换键。之所以把内容指纹并进键里,是因为会改内容的入口散落在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel` 的通用函数中,靠「每处都记得重铸键」迟早漏一处。 @@ -28,9 +31,11 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2 | --- | --- | | `model3d-generation/Model3dGenerationModal.tsx` | 提交面板外壳:两个子项共用的表单、状态区、提交按钮 | | `model3d-generation/Model3dGenerationModal.test.tsx` | 面板行为用例 | -| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源(用例并入 `Model3dGenerationModal.test.tsx`:这些字段只在面板里出现,单独挂一个 harness 只是重复) | -| `model3d-generation/Model3dGenerationFormModel.ts` | 档位 → 六个必填参数的唯一映射、价格计算(读运行时定价缓存) | -| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、价格、缺价不可提交的用例 | +| `model3d-generation/Model3dGenerationForm.tsx` | 子项专属字段:文生提示词、图生参考图来源、模型版本选择器与按能力禁用的档位 / 开关(用例并入 `Model3dGenerationModal.test.tsx`:这些字段只在面板里出现,单独挂一个 harness 只是重复) | +| `model3d-generation/Model3dGenerationFormModel.ts` | 模型版本与档位 → 六个必填参数的唯一映射、可选模型版本、价格计算(读运行时定价缓存) | +| `model3d-generation/Model3dGenerationFormModel.test.ts` | 映射、模型版本收敛、价格、缺价不可提交的用例 | +| `model3d-generation/Model3dGenerationValidation.ts` | 与 provider / api-server 对齐的能力校验(模型族、分件前置条件、面数边界、平台必显式项)与原因文案的唯一来源 | +| `model3d-generation/Model3dGenerationValidation.test.ts` | 逐条规则与边界用例(对齐 provider 的同名用例) | | `model3d-generation/Model3dGenerationSubmission.ts` | 校验后的请求体组装、落点字段、幂等键铸造(代次 + 内容指纹) | | `model3d-generation/Model3dGenerationSubmission.test.ts` | 请求体全量非空、参考图收敛、幂等键铸造与换键用例 | | `model3d-generation/useModel3dGenerationTask.ts` | 调用客户端 submit、排队、失败与重试收口(复用既有排队链路) | @@ -92,11 +97,25 @@ Parent Spec: `docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-2 仍未验证的部分需要环境:`model3d` 定价段写入运行配置后的真实扣费与排队回填、provider 端到端产物。 +### 追加一轮(2026-09-24):模型版本可选 + 参数面按能力门禁 + 端点/提交体类型收口 + +| 变更 | 证据 | +| --- | --- | +| 模型版本可选,可选集只取当前端点有底价的版本 | `Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺定价给空集)、`Model3dGenerationModal.test.tsx`(菜单里没有无价的 `P1`,切到 `P2` 后报价跟着换) | +| 报价与提交体读同一个版本 | `Model3dGenerationSubmission.test.ts`(`generation.model` 跟对话框走)、`Model3dGenerationFormModel.test.ts`(缺价版本不可提交,不回落默认价) | +| 能力组合与 provider 逐条对齐 | `Model3dGenerationValidation.test.ts`(12 例:模型族能力、分件三个前置条件、v3.0/v3.1/v2.5/P1/P2 的面数边界、平台必显式项);`Model3dGenerationFormModel.test.ts`(不支持组合直接判非法) | +| 换版本 / 切档位时参数收敛 | `Model3dGenerationFormModel.test.ts`、`Model3dGenerationModal.test.tsx`(高清贴图 → 标准贴图、方形布线自动关掉并禁用) | +| 端点与提交体由类型绑死 | `useModel3dGenerationTask.test.tsx`(图生走图生函数、文生函数不被调用);`npm run typecheck` 通过(旧单函数已删除) | +| v2.5 的贴图档位不带 `textureQuality` | `Model3dGenerationFormModel.test.ts` | + +本轮验证:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx packages/image-canvas-react`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;`npx eslint` 对改动文件无告警。 + ## 验证命令 ```bash npx vitest run src/components/image-editor/model3d-generation npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +npx eslint src/components/image-editor/model3d-generation npm run typecheck npm run check:encoding node scripts/check-doc-index.mjs diff --git a/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md index 09698a413..b56f4ed2d 100644 --- a/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】Tripo生成前端入口-2026-09-21.md @@ -14,7 +14,7 @@ Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21. ## 范围 - 底部工具栏入口与子选项菜单,形态照 `music` / `spec` 的「一项入口 → 浮动子选项 → 各自一个提交面板」。 -- 两个提交面板:档位化的参数选择、提交前价格展示、文生提示词、图生参考图来源。 +- 两个提交面板:模型版本选择(只列当前端点有底价的版本)、档位化的参数选择、按模型能力禁用的开关、提交前价格展示、文生提示词、图生参考图来源。 - 前端铸造 `Idempotency-Key`,并按契约全量非空提交必填参数。 - 画布占位框、入队、任务侧栏、失败与退款提示、终态刷新回填。 - 3D 定价段的前端消费:只读实时定价查询。 @@ -32,7 +32,7 @@ Related: `docs/adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21. ## 验收标准 1. 工具栏出现「生成 3D 模型」,点开只有两个子项,子项文案与任务侧栏 `phaseLabel` 一致。 -2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`。 +2. 每次提交的请求体包含全部必填参数(含六个定价参数),不存在缺字段或 `undefined`;`generation.model` 等于面板上选中的模型版本,面板选项不会出现该端点没有底价的版本。 3. 价格只来自 `GET /api/editor/generation-pricing` 的 `model3d` 段;该段缺失时两个子面板都不可提交,且不发任何请求。 4. 每次提交携带 `Idempotency-Key`;同一次提交重放不产生第二个 operation;用户主动重试铸造新键。 5. 提交后画布出现占位框,任务侧栏出现该任务并显示「文生 3D 模型」/「图生 3D 模型」;成功后占位框被正式资源图层替换。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cf81aed84..a2df2c03f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,27 @@ # 决策记录 +## 2026-09-24 3D 参数面板开放模型选择:版本取「本端点有底价」的键,能力规则抄 provider,端点与提交体由类型绑死 + +- 背景:3D 面板此前把请求体与报价里的 `model` 一起写死成 `DEFAULT_MODEL3D_MODEL_VERSION`(`v3.1-20260211`),而公开读模型的定价段是按 `endpoint × modelVersion` 展开的;后端加载即校验「每个契约版本 × 两个端点都必须有底价」,于是定价表里已配好的 `v3.0` / `v2.5` / `P1` / `P2` 四个版本在前端永远不可达,用户也看不到任何模型选择入口。同一轮还暴露两个类型层面的口子:提交函数签名是「endpoint 联合 + 提交体联合」两个彼此独立的字段,把图生请求体发到文生地址照样编译通过;`Model3dPricingConfig.basePrices` 内层键写成 `string`,写错的版本号在编译期没有任何反馈。 +- 决策: + 1. `GenerateDialogState.model3dModel` 成为用户可选参数,缺省 `v3.1-20260211`;面板选项只列**当前端点有底价**的版本(`resolveModel3dModelOptions`),报价与请求体的 `generation.model` 读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`。 + 2. 换版本 / 切档位时按新版本能力收敛参数(高清贴图 → 标准贴图、关掉该版本不支持的开关)并开新一代次,面板上不留已经失效的勾选。 + 3. 前端保留一份与 provider 对齐的参数校验 `model3d-generation/Model3dGenerationValidation.ts`,逐条复刻 `platform-tripo::common::validation::validate_generation_options` 与 `api-server::tripo3d::validation::PricingParamView::validate`:模型族能力、分件的三个前置条件、`face_limit_bounds`、平台必显式项。抄的是规则不是数值,两端顺序一致(平台口径在前),冲突以 provider 为准。 + 4. 提交端点与提交体由类型绑死:`submitModel3dTextToModelRequest` / `submitModel3dImageToModelRequest` 一个端点一个函数,`Model3dSubmissionPlan` 做成 `endpoint` 判别联合;`Model3dPricingConfig.basePrices` 的两层键改成契约枚举(`Partial>>>`)。 +- 原因:价格与参数是同一件事的两面——定价按版本分档,把版本写死等于让其余四个版本的定价成为死配置;面板不给版本选择,用户既看不到这些版本,也不可能用上已经付费配置的档位。校验必须抄一份到前端,是因为契约与公开读模型只暴露「价格」,不暴露「哪个版本支持哪些参数」,不抄就只能等用户点提交后由 provider 的字段级报错来教育。类型收口(判别联合 + 契约枚举做键)让同类端点 / 版本错配在 `tsc` 阶段暴露,而不是等远端按字段报错。 +- 代价与取舍:前端多了一份 provider 规则镜像,provider 改规则时两边要一起改;用 `Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)与 `Model3dGenerationFormModel.test.ts` 的组合表把两边钉住,冲突时以 provider 为准(失败关闭)。运行期的「契约版本列表」仍只能靠前端常量表 `MODEL3D_MODEL_TABLE`:ts-rs 只导出联合**类型**,导不出运行期数组,所以表写成 `Record`,契约新增版本后 `npm run contracts:model3d:generate` 会让这里直接编译失败。更干净的做法是后端公开读模型补一份 `modelCapabilities`(版本 → 支持参数 → 加价项),前端就不用镜像规则,本轮未做。 +- 影响面:`src/components/image-editor/model3d-generation/*`、`src/components/image-editor/ImageCanvasOptionChoice.tsx`(新,选项行抽共享)、`src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx`、`src/components/image-editor/ImageCanvasEditorTypes.ts`、`src/services/image-editor/editorProjectClient.ts`、[实施计划](../plans/【实施计划】Tripo生成前端入口-2026-09-21.md)、[里程碑](../plans/【里程碑】Tripo生成前端入口-2026-09-21.md)。 +- 验证方式:`npx vitest run src/components/image-editor/model3d-generation`(75 passed)、`npx vitest run src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx`、`npm run typecheck`、`npx eslint`(改动文件无告警)。 + +## 2026-09-24 画布底部工具栏改用换行兜溢出,不再靠横向滚动 + +- 背景:13 个工具 + 3 个分隔符放进宿主限宽的 `.image-canvas-editor__bottom-toolbar`(`max-width: min(calc(100% - 6.6rem), 34rem)`,约 614px)后必然溢出,共享的 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 兜住——细到几乎不可见的滚动条让尾部工具(3D 入口等)在桌面端直接「消失」,用户只会看到工具栏被截断。 +- 决策:共享工具栏改 `flex-wrap: wrap`(只换行、不再滚动),宿主上限 34rem → 42rem 并 `justify-content: center`;AGC 资源画布的宿主覆盖删掉与共享默认重复的 `overflow` 覆盖,只保留限宽。 +- 原因:工具条是「入口清单」,被截断等于功能不存在;换行是唯一在任意宽度下都能保证每个入口可见且可点的方案,横向滚动在隐藏滚动条的容器里连「还能滚」这件事都传达不到。 +- 代价与取舍:工具变多时工具栏会占两行、抬高画布底部,视觉上不如一行整齐;换来的是入口不再被容器宽度吃掉。判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见。 +- 影响面:`packages/image-canvas-react/src/{CanvasChrome.tsx,styles.css}`、`src/index.css`、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css`。 +- 验证方式:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`、`npx vitest run packages/image-canvas-react`;1280px 视口实测不再有横向溢出。 + ## 2026-09-24 Tripo 配置改为启动期失败关闭:网关无内置默认值,缺配置的进程直接起不来 - 背景:`TRIPO_BASE_URL` / `TRIPO_API_KEY` 原来只有请求期一道判断,而请求期根本挡不住部署错误:HTTP 角色收 3D 单时不碰 provider,worker 只在自己真的跑到那个 job 时才构造 `TripoSettings`,于是缺配置的部署形态是「进程起来了、`/healthz` 全绿、用户拿到 202」,失败只在任务记录里现身。同时 `AppConfig::default()` 带着内置网关 `https://openapi.tripo3d.com/v3`,`tripo_settings()` 又把超时钳到 `.max(1)`、重试钳到 `.min(10)`,一个写错的配置会被翻译成「能跑但行为诡异」。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 440c4278c..0bb81203e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,37 @@ # 踩坑与排障记录 +## 隐藏滚动条的横向滚动容器不等于「放得下」 + +- **现象**:桌面端画布底部工具栏尾部几个工具(含「生成 3D 模型」入口)看不到,用户反馈成「工具栏变成横向可滚动的了」「是不是限宽了」。 +- **原因**:共享 `.genarrative-image-canvas__toolbar` 用 `overflow-x: auto` + `scrollbar-width: thin` 处理溢出,宿主 `.image-canvas-editor__bottom-toolbar` 又把宽度限到约 614px;13 个工具必然溢出,但细滚动条在桌面端几乎不可见,也没有「还有更多」的任何提示,尾部工具等同于消失。 +- **处理(现行口径)**:工具栏 `flex-wrap: wrap`,只换行不滚动;宿主上限放宽并居中,AGC 宿主不再重复覆盖。验收判据固定为 `scrollWidth === clientWidth` 且最后一个按钮完整可见,不能只看「滚动条能不能拖」。 +- **验证**:`npx vitest run src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx` 与 `packages/image-canvas-react` 用例;1280px 视口实测工具栏单行、尾部工具可见。 +- **关联**:`packages/image-canvas-react/src/CanvasChrome.tsx`、`packages/image-canvas-react/src/styles.css`、`src/index.css`。 + +## 定价按模型版本分档时,前端不能把版本写死成常量 + +- **现象**:3D 生成面板没有任何模型选择入口,四个已经在定价表里配好底价的版本(`v3.0` / `v2.5` / `P1` / `P2`)在前端完全不可达;提交的 `generation.model` 永远是 `v3.1-20260211`。 +- **原因**:请求体与报价都把 `model` 写死成 `DEFAULT_MODEL3D_MODEL_VERSION`,而公开读模型的 `basePrices` 是按 `endpoint × modelVersion` 展开的;面板不做版本选择,也就没人去读那些键,定价配置里多数行成了死数据。 +- **处理(现行口径)**:版本由用户选,选项取「当前端点有底价」的版本;报价与请求体读同一个 `resolveModel3dModelVersion(dialog.model3dModel)`;换版本时按新版本能力收敛档位与开关并开新一代次。 +- **验证**:`Model3dGenerationFormModel.test.ts`(`resolveModel3dModelOptions` 两个端点各一例、缺价版本不可提交)、`Model3dGenerationModal.test.tsx`(选项不含无价版本、切版本后报价跟着换)。 +- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationFormModel.ts`、`Model3dGenerationSubmission.ts`、`src/services/image-editor/editorProjectClient.ts`。 + +## 「endpoint 联合 + 提交体联合」是两个独立的联合,配错端点编译能过 + +- **现象**:3D 生成提交函数的签名是 `{ endpoint: 'text-to-model' | 'image-to-model'; body: TextRequest | ImageRequest }`,把图生请求体发到文生地址没有任何类型错误,只能等远端按字段报错;同类口子还有定价类型的 `basePrices` 内层键写成 `string`,写错的模型版本号在编译期无反馈。 +- **原因**:两个联合类型彼此独立,类型系统不要求它们同进同退;只要两边各自合法,任意组合都成立。写死的常量(默认模型版本)也属于同一类问题:类型正确、语义错位。 +- **处理(现行口径)**:提交函数按端点一分为二(端点字面量与提交体类型在同一个签名里绑死),提交计划改成 `endpoint` 判别联合;`MODEL3D_MODEL_TABLE` 用 `Record`,定价键改成契约枚举。契约新增版本 / 端点时先编译失败,而不是先上生产。 +- **验证**:`useModel3dGenerationTask.test.tsx`(图生只走图生函数、文生函数不被调用)、`npm run typecheck`;`Model3dGenerationValidation.test.ts` 覆盖契约声明的 5 个版本。 +- **关联**:`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts`。 + +## 面板禁用态不能只靠「后端会拒」兜底 + +- **现象**:用户能选到必然被 provider 拒的组合(v2.5 + 贴图档位、P2 + 高清贴图、分件 + 贴图档位),提交后拿到字段级错误,白等一次往返,且错误文案对用户没有指导意义。 +- **原因**:契约与公开读模型只暴露价格,不暴露「哪个版本支持哪些参数」;前端此前的组合判定只覆盖分件与贴图档位,其余全交给 provider。 +- **处理(现行口径)**:前端保留 `model3d-generation/Model3dGenerationValidation.ts` 镜像 provider 与平台侧规则(抄规则不抄数值,冲突以 provider 为准),面板的禁用态与禁用原因读同一份常量;长期正解是后端读模型补 `modelCapabilities`,让前端不再维护第二份规则。 +- **验证**:`Model3dGenerationValidation.test.ts`(12 例,含五个版本的能力与面数边界)、`Model3dGenerationFormModel.test.ts` 的档位 × 开关组合表。 +- **关联**:`src/components/image-editor/model3d-generation/Model3dGenerationValidation.ts`、`server-rs/crates/platform-tripo/src/common/validation.rs`、`server-rs/crates/api-server/src/tripo3d/validation.rs`。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index f3f8c917b..ade380681 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -4,6 +4,7 @@ import type { EditorSoundEffectModel, } from '../../../packages/shared/src/contracts/editorAudio'; import type { EditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene'; +import type { Model3dModelVersion } from '../../../packages/shared/src/contracts/model3d'; import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, @@ -299,6 +300,11 @@ export type GenerateDialogState = { characterAnimationFrameCount?: EditorCharacterAnimationFrameCount; characterAnimationDurationSeconds?: 4 | 5 | 6; characterAnimationResult?: EditorCharacterAnimationGenerationResult; + /** + * 3D 模型版本:用户可选,缺省(含老快照)回落到 `DEFAULT_MODEL3D_MODEL_VERSION`。 + * 报价与请求体都读它,面板上的模型选择器也写它。 + */ + model3dModel?: Model3dModelVersion; model3dTier?: Model3dGenerationTier; model3dQuad?: boolean; model3dSmartLowPoly?: boolean; diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx index 800eef79a..01afb3368 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.tsx @@ -25,6 +25,7 @@ import { resolveDialogExtraImageReferenceLimit, resolveEditorImageSizeLabel, } from './ImageCanvasGenerationModel'; +import { ImageCanvasOptionChoice } from './ImageCanvasOptionChoice'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; type ImageCanvasGenerationImageOptionsViewProps = { @@ -93,34 +94,6 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) { }; } -function OptionChoice({ - children, - selected, - className, - ariaLabel, - onClick, -}: { - children: ReactNode; - selected: boolean; - className?: string; - ariaLabel?: string; - onClick: () => void; -}) { - return ( - - ); -} - export function ImageCanvasGenerationImageOptionsView({ dialog, setGenerateDialog, @@ -284,7 +257,7 @@ export function ImageCanvasGenerationImageOptionsView({
{selection.options.aspectRatios.map((aspectRatio) => ( - + ))}
@@ -310,7 +283,7 @@ export function ImageCanvasGenerationImageOptionsView({
{selection.options.imageSizes.map((imageSize) => ( - updateDialog({ imageSize })} > {imageSize} - + ))}
@@ -395,7 +368,7 @@ export function ImageCanvasGenerationImageOptionsView({ {modelOptions.map((option) => { const selected = selectedModel === option.value; return ( - + ); })} diff --git a/src/components/image-editor/ImageCanvasOptionChoice.tsx b/src/components/image-editor/ImageCanvasOptionChoice.tsx new file mode 100644 index 000000000..fdc159604 --- /dev/null +++ b/src/components/image-editor/ImageCanvasOptionChoice.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +/** + * 画布参数浮层里的「多选一」选项行:模型、尺寸这类选择共用同一条样式与 `aria-pressed` 语义。 + * + * 抽出来是因为它已经在 2 个面板里重复(生成图片面板的模型菜单、3D 生成面板的模型菜单), + * 样式类名一旦各写一份就可能只改到一处。 + */ +export function ImageCanvasOptionChoice({ + children, + selected, + className, + ariaLabel, + onClick, +}: { + children: ReactNode; + selected: boolean; + className?: string; + ariaLabel?: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx index a830daff5..477041ee7 100644 --- a/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx +++ b/src/components/image-editor/model3d-generation/Model3dGenerationForm.tsx @@ -1,4 +1,4 @@ -import { ImageIcon } from 'lucide-react'; +import { Check, ChevronDown, Cpu, ImageIcon } from 'lucide-react'; import type { CSSProperties, Dispatch, @@ -6,7 +6,9 @@ import type { RefObject, SetStateAction, } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { Model3dModelVersion } from '../../../../packages/shared/src/contracts/model3d'; import { AutoGrowTextArea } from '../../common/AutoGrowTextArea'; import { PlatformFloatingMenu, @@ -18,20 +20,32 @@ import type { GenerateDialogState, UploadTarget, } from '../ImageCanvasEditorTypes'; +import { ImageCanvasOptionChoice } from '../ImageCanvasOptionChoice'; import { ImageCanvasReferenceSlot } from '../ImageCanvasReferenceSlot'; +import { useImageCanvasFloatingOptionDismiss } from '../useImageCanvasFloatingOptionDismiss'; import { + applyModel3dModelChange, applyModel3dPromptChange, applyModel3dReferenceRemoval, applyModel3dSwitchChange, applyModel3dTierChange, + isModel3dTierSupported, + MODEL3D_DETAILED_TIER_DISABLED_REASON, + MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON, MODEL3D_GENERATION_TIERS, MODEL3D_SWITCH_OPTIONS, MODEL3D_TEXT_PROMPT_MAX_LENGTH, + resolveModel3dModelVersion, } from './Model3dGenerationFormModel'; +import { resolveModel3dModelCapabilities } from './Model3dGenerationValidation'; type Model3dGenerationFormProps = { dialog: GenerateDialogState; disabled: boolean; + /** + * 当前端点有底价的模型版本;由面板外壳统一读定价缓存后传入,表单不各自读一次配置。 + */ + modelOptions: ReadonlyArray<{ value: Model3dModelVersion; label: string }>; updateDialog: ( updater: (dialog: GenerateDialogState) => GenerateDialogState, ) => void; @@ -51,6 +65,7 @@ type Model3dGenerationFormProps = { export function Model3dGenerationForm({ dialog, disabled, + modelOptions, updateDialog, generationReferenceButtonRef, isGenerationReferenceMenuOpen = false, @@ -65,6 +80,24 @@ export function Model3dGenerationForm({ const references = dialog.generationReferences ?? []; const tier = dialog.model3dTier ?? 'textured-standard'; const isTextureTier = tier !== 'geometry-only'; + const model = resolveModel3dModelVersion(dialog.model3dModel); + const capabilities = resolveModel3dModelCapabilities(model); + const selectedModelLabel = + modelOptions.find((option) => option.value === model)?.label ?? + // 定价段缺了当前版本时选项里查不到它:仍把版本号显示出来,价格区会同时报缺价原因。 + model; + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const modelButtonRef = useRef(null); + const modelMenuBoundaryRefs = useMemo(() => [modelButtonRef], []); + const closeModelMenu = useCallback(() => setIsModelMenuOpen(false), []); + + useImageCanvasFloatingOptionDismiss({ + isOpen: isModelMenuOpen, + boundaryRefs: modelMenuBoundaryRefs, + onDismiss: closeModelMenu, + restoreFocusRef: modelButtonRef, + }); + const toggleSwitch = ( field: 'model3dQuad' | 'model3dSmartLowPoly' | 'model3dGenerateParts', checked: boolean, @@ -126,34 +159,115 @@ export function Model3dGenerationForm({ /> )}
+
+ } + onClick={() => setIsModelMenuOpen((open) => !open)} + > + + + {selectedModelLabel} + + + {isModelMenuOpen + ? renderEditorPortal( + event.stopPropagation()} + > +
+ {modelOptions.map((option) => { + const selected = model === option.value; + return ( + { + closeModelMenu(); + updateDialog((currentDialog) => + applyModel3dModelChange( + currentDialog, + option.value, + ), + ); + }} + > + + {option.label} + + ); + })} +
+
, + ) + : null} +
- {MODEL3D_GENERATION_TIERS.map((option) => ( - - updateDialog((currentDialog) => - applyModel3dTierChange(currentDialog, option.value), - ) - } - > - {option.label} - - ))} + {MODEL3D_GENERATION_TIERS.map((option) => { + const blocked = !isModel3dTierSupported(capabilities, option.value); + return ( + + updateDialog((currentDialog) => + applyModel3dTierChange(currentDialog, option.value), + ) + } + > + {option.label} + + ); + })}
{MODEL3D_SWITCH_OPTIONS.map((option) => { - const blocked = + const capabilityBlocked = !capabilities[option.capability]; + const tierBlocked = option.field === 'model3dGenerateParts' && isTextureTier; + const blocked = capabilityBlocked || tierBlocked; + const blockedReason = tierBlocked + ? MODEL3D_GENERATE_PARTS_TEXTURE_TIER_REASON + : option.disabledReason; return (