diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c475d66a..a1ce36ee 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine + +- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。 +- 决策:所有 GPT-image-2 生图请求统一走 VectorEngine `POST /v1/images/generations`,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2-all`,请求体不再携带 `official_fallback`,参考图字段改为 `image`。APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 +- 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。 +- 验证方式:执行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run api-server` + `/healthz` 做后端 smoke。 +- 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 + +## 2026-05-08 Hyper3D Rodin Gen-2 只通过后端安全代理接入 + +- 背景:需要接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型,但供应商 API Key 不能进入前端、文档或 Git;本次只是外部副作用代理,不需要新增平台真相表。 +- 决策:Hyper3D 统一走 `api-server` 的 `/api/assets/hyper3d/*` 鉴权路由,配置只读取 `HYPER3D_BASE_URL` / `HYPER3D_API_KEY` / `HYPER3D_MODEL_REQUEST_TIMEOUT_MS` 及兼容 `RODIN_*` 变量;生成提交、状态查询和下载列表都由后端代理。首版不写 SpacetimeDB、不确认 `asset_object`,下载链接后续由调用方决定是否进入 OSS 资产链。 +- 影响范围:`api-server` 外部服务配置、Hyper3D route、`shared-contracts` / TS contract、前端 service、生产环境示例和外部服务环境变量文档。 +- 验证方式:执行 `cargo test -p api-server hyper3d --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts hyper3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck` 和编码检查;真实 API smoke 只在本地私密环境配置 key 后手动执行。 +- 关联文档:`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 + ## 2026-05-08 APIMart 接口统一携带 `official_fallback` - 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0d7c2698..a19f550e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -43,6 +43,14 @@ - 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。 - 关联:`.hermes/README.md`。 +## GPT-image-2 不再读 APIMart 图片配置 + +- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 +- 原因:2026-05-09 后 GPT-image-2 图片生成已切到 VectorEngine `gpt-image-2-all`,APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。 +- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai`、`VECTOR_ENGINE_API_KEY`、`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认路径为 `/v1/images/generations`、模型为 `gpt-image-2-all`、参考图字段为 `image`。 +- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 +- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 + ## 旧后端路线文档造成判断漂移 - 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。 @@ -67,6 +75,14 @@ - 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。 - 关联:`docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md`。 +## 本地 SpacetimeDB publish 403 优先查 CLI root 身份 + +- 现象:`spacetime publish` 在 `Pre-publish check` 阶段返回 `403 Forbidden`,提示当前 identity 无权对目标 database identity 执行 `update database`。 +- 原因:裸 `spacetime` 命令使用了全局 CLI 登录态,但本地开发库权限绑定在 `server-rs/.spacetimedb/local` root-dir 的另一个身份上。 +- 处理:对比 `spacetime login show` 和 `spacetime --root-dir server-rs/.spacetimedb/local login show`;本地开发发布必须使用 `npm run dev:rust`,或显式追加 `--root-dir=server-rs/.spacetimedb/local`。 +- 验证:`spacetime --root-dir server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 能看到目标库;重新发布不再使用无权限的全局 identity。 +- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`。 + ## Vite SPA fallback 吞掉 API 请求 - 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。 @@ -114,6 +130,7 @@ - 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。 - Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。 - 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。 + - 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。 - 处理:优先用 `npm run api-server`、`npm run dev:rust` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。 - 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 - 关联:`scripts/api-server-dev.mjs`、`scripts/api-server-maincloud.mjs`、`scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。 @@ -139,8 +156,10 @@ - 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。 - 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。 - 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。 -- 验证:`npm run test -- src/services/apiClient.test.ts` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`。 -- 关联:`src/services/apiClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 +- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。 +- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。 +- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。 +- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 ## Rust 冷编译导致 api-server 健康检查误超时 @@ -235,11 +254,11 @@ ## Rust 构建不要让不可用的 sccache 阻断 rustc -- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`,真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 -- 原因:环境或 Jenkinsfile 设置了 `RUSTC_WRAPPER=sccache`,但当前 Windows/Linux agent 上没有可执行的 `sccache`,或 PATH 中的 `sccache` shim 损坏。 -- 处理:本地临时排障可执行 `Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue` 后重跑 Cargo;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 -- 验证:`rustc -Vv` 能输出版本;`cargo` 不再尝试调用不可用的 `sccache`;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 -- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。 +- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。 +- 处理:本地临时排障可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`;`npm run dev:rust` 的 SpacetimeDB publish 已在命中 sccache 通信失败时自动清空 wrapper 重试一次;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。 +- 验证:`rustc -Vv` 能输出版本;清空 wrapper 后 `cargo check --target=wasm32-unknown-unknown --release` 能通过;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。 +- 关联:`scripts/dev-rust-stack.sh`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 @@ -256,3 +275,11 @@ - 处理:Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`;API 和领域构造层拒绝非 `User`。 - 验证:非 `user` scope 返回错误;相关测试覆盖 `Site` / `Module` / `Work` 被拒绝。 - 关联:`docs/technical/RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`docs/technical/ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。 + +## 拼图发布 409 不一定是接口故障 + +- 现象:拼图结果页点击发布后,控制台出现 `POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions 409 (Conflict)`,用户只看到发布失败。 +- 原因:`publish_puzzle_work` 是资产操作发布入口,发布前会预扣 `1` 枚光点;余额不足时后端按业务冲突返回 `409 CONFLICT`,`details.message` 为 `光点余额不足`。 +- 处理:前端发布弹窗在用户点击发布后必须保留并展示后端业务错误,不能只把错误写到弹窗背后的页面 banner。 +- 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `光点余额不足`。 +- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。 diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 943d9a48..d8821cd3 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -37,12 +37,16 @@ GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false APIMART_BASE_URL= APIMART_API_KEY= -APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 -VECTOR_ENGINE_BASE_URL= +VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai VECTOR_ENGINE_API_KEY= +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 +HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 +HYPER3D_API_KEY= +HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000 + VOLCENGINE_SPEECH_API_KEY= VOLCENGINE_SPEECH_APP_ID= VOLCENGINE_SPEECH_ACCESS_KEY= diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md index 61da363f..115d7c0c 100644 --- a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -100,3 +100,13 @@ 3. 发现 创作 Tab 必须位于中间,并使用原推荐 Tab 的星光图标,保持几何和视觉上的主行动入口。推荐 Tab 改用游戏手柄图标,避免与创作图标重复。 + +## 9. 2026-05-08 新用户默认发现与推荐门禁补充 + +未登录新用户首次进入平台时默认落在“发现”Tab,不再直接进入“推荐”Tab 的内嵌运行态。 + +- 未登录用户点击底部或侧边栏“推荐”Tab 时,页面可切到推荐封面预览态,同时打开登录弹窗。 +- 未登录状态下推荐页只展示当前推荐作品封面,不启动作品运行态,不展示推荐作品信息区。 +- 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。 +- 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。 +- 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。 diff --git a/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md b/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md index f727a5e8..78f31f19 100644 --- a/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md +++ b/docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md @@ -34,16 +34,21 @@ GENARRATIVE_LLM_BASE_URL= GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL= -# APIMart / OpenAI 兼容图片网关 +# APIMart / OpenAI 兼容 Responses 文本网关 APIMART_BASE_URL= APIMART_API_KEY= -APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 -# VectorEngine / Suno / Vidu 音频生成网关 -VECTOR_ENGINE_BASE_URL= +# VectorEngine / GPT-image-2 / Suno / Vidu 生成网关 +VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai VECTOR_ENGINE_API_KEY= +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000 +# Hyper3D Rodin Gen-2 3D 模型生成 +HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 +HYPER3D_API_KEY= +HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000 + # 火山引擎豆包语音 ASR / TTS VOLCENGINE_SPEECH_API_KEY= VOLCENGINE_SPEECH_APP_ID= @@ -84,6 +89,9 @@ ARK_CHARACTER_VIDEO_MODEL / DASHSCOPE_CHARACTER_VIDEO_MODEL VOLCENGINE_SPEECH_API_KEY / VOLCENGINE_API_KEY VOLCENGINE_SPEECH_APP_ID / VOLCENGINE_ACCESS_KEY_ID VOLCENGINE_SPEECH_ACCESS_KEY / VOLCENGINE_SECRET_ACCESS_KEY +HYPER3D_BASE_URL / RODIN_BASE_URL +HYPER3D_API_KEY / RODIN_API_KEY +HYPER3D_MODEL_REQUEST_TIMEOUT_MS / RODIN_MODEL_REQUEST_TIMEOUT_MS ``` ## 运行时行为 @@ -93,8 +101,10 @@ VOLCENGINE_SPEECH_ACCESS_KEY / VOLCENGINE_SECRET_ACCESS_KEY 3. 文本 LLM provider 为 `ark` 且未配置 `GENARRATIVE_LLM_BASE_URL` 时,仍回退到 Ark 公开基础 URL。 4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。 5. 具体模型名缺失时不在配置层伪造默认模型,调用到对应能力时由下游配置校验返回缺配置错误。 -6. VectorEngine 音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,不复用 `APIMART_*`、`GENARRATIVE_LLM_*` 或前端变量。 +6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*`、`GENARRATIVE_LLM_*` 或前端变量。 7. 火山引擎语音能力由 `platform-speech` 收口协议帧与上游鉴权,`api-server` 只暴露平台鉴权后的代理路由,不向前端返回任何密钥字段。 +8. Hyper3D Rodin Gen-2 使用公开默认 `https://api.hyper3d.com/api/v2`,API Key 只读取 `HYPER3D_API_KEY` / `RODIN_API_KEY`,不复用文本 LLM、图片或音频网关密钥。 +9. APIMart 当前只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态理解链路;GPT-image-2 图片生成不得再读取 APIMart 配置。 ## 示例文件 diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md index c90935f2..0995f68d 100644 --- a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md @@ -135,5 +135,5 @@ 2. 再请求当前 Rust API 目标,例如 `http://127.0.0.1:3100/api/auth/login-options` 或 `http://127.0.0.1:8082/api/auth/login-options`。 3. 若直连 API 成功而 3000 返回 `500`,检查 `RUST_SERVER_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 是否指向仍在监听的 API 端口。 4. `npm run dev` / `npm run dev:rust` 完整栈默认由脚本计算 API 端口;加载 `.env.local` 给后端使用后,脚本必须重新固定 `RUST_SERVER_TARGET`,避免 `.env.local` 中的旧代理目标覆盖本次启动的实际 API 端口。 -5. `npm run dev:web` 只启动前端,不会自动拉起 Rust API;如果单独使用它,脚本会先探测 `.env.local` / 当前环境里声明的目标,再回退到本机常见端口,最终只会接入一个真实可用的 `api-server`。 +5. `npm run dev:web` 只启动前端,不会自动拉起 Rust API;如果 `.env.local` / 当前环境已经显式声明 `GENARRATIVE_RUNTIME_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_API_TARGET` 或 `GENARRATIVE_API_PORT`,脚本必须固定使用该目标。目标当下不可用时只打印警告,不自动切到另一个端口,避免前端进程长时间绑定到随后会停掉的临时 API。 6. 如果 `3000` 仍然返回 `500`,先确认浏览器是不是还开着旧的前端进程。当前脚本如果因为端口占用漂移到 `3001` / `3002`,应直接关掉旧进程后重启,而不是继续用旧的 3000 页面判断登录入口状态。 diff --git a/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md new file mode 100644 index 00000000..97b3c1b0 --- /dev/null +++ b/docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md @@ -0,0 +1,128 @@ +# Hyper3D Rodin Gen-2 3D 模型生成接入方案 2026-05-08 + +## 1. 范围 + +本方案用于接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型能力。 + +本次只做后端安全代理与前端可复用 client,不新增 SpacetimeDB 表,不落正式资产对象,不把 Hyper3D API Key 下发到前端。生成结果仍由调用方在拿到下载链接后决定是否进入 OSS / `asset_object` 的正式资产链。 + +## 2. 参考接口 + +参考文档: + +- `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2` +- `https://developer.hyper3d.ai/api-specification/check-status` +- `https://developer.hyper3d.ai/api-specification/download-results` + +上游接口: + +```text +POST https://api.hyper3d.com/api/v2/rodin +POST https://api.hyper3d.com/api/v2/status +POST https://api.hyper3d.com/api/v2/download +``` + +Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`。 + +## 3. 环境变量 + +```text +HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 +HYPER3D_API_KEY= +HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000 +``` + +兼容变量: + +```text +RODIN_BASE_URL +RODIN_API_KEY +RODIN_MODEL_REQUEST_TIMEOUT_MS +``` + +说明: + +1. `HYPER3D_API_KEY` / `RODIN_API_KEY` 只允许写入本地或生产私密环境,不提交到 Git。 +2. 缺少 API Key 时,后端返回 `503 SERVICE_UNAVAILABLE`。 +3. `HYPER3D_BASE_URL` 默认使用公开 API 基础地址;如果团队后续改用代理网关,可通过环境变量覆盖。 + +## 4. 后端路由 + +新增 4 个鉴权路由: + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `POST` | `/api/assets/hyper3d/text-to-model` | 提交 Rodin Gen-2 文生模型任务 | +| `POST` | `/api/assets/hyper3d/image-to-model` | 提交 Rodin Gen-2 图生模型任务 | +| `POST` | `/api/assets/hyper3d/status` | 使用 `subscriptionKey` 查询任务状态 | +| `POST` | `/api/assets/hyper3d/download` | 使用 `taskUuid` 获取模型下载列表 | + +文生模型请求最小体: + +```json +{ + "prompt": "一只低多边形宝箱,适合 RPG 游戏资产", + "geometryFileFormat": "glb", + "material": "PBR", + "quality": "medium", + "meshMode": "Quad", + "previewRender": true +} +``` + +图生模型请求最小体: + +```json +{ + "imageDataUrls": ["data:image/png;base64,..."], + "prompt": "保留主体轮廓,生成游戏可用 3D 模型", + "conditionMode": "concat", + "geometryFileFormat": "glb" +} +``` + +## 5. 约束 + +1. 图片只接受 `data:image/png|jpeg|webp;base64,...`,最多 5 张。 +2. 单张图片解码后不超过 10MB。 +3. `geometryFileFormat` 限定为 `glb/usdz/fbx/obj/stl`,默认 `glb`。 +4. `material` 限定为 `PBR/Shaded/All`,默认 `PBR`。 +5. `quality` 限定为 `high/medium/low/extra-low`,默认 `medium`。 +6. `meshMode` 限定为 `Quad/Raw`,默认 `Quad`。 +7. `addons` 首版只允许 `HighPack`。 +8. `bboxCondition` 必须为 3 个正数,按上游要求序列化为 JSON 字符串。 + +## 6. 返回语义 + +提交任务成功后返回: + +```json +{ + "ok": true, + "provider": "hyper3d-rodin", + "mode": "text-to-model", + "taskUuid": "task-uuid", + "subscriptionKey": "subscription-key", + "jobUuids": ["job-uuid"], + "message": "Submitted.", + "tier": "Gen-2" +} +``` + +状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`。下载接口只返回上游 `list.name` 与 `list.url`,不在后端转存文件。 + +## 7. 验收 + +建议执行: + +```bash +npm run check:encoding +npm run typecheck + +cd server-rs +cargo test -p shared-contracts hyper3d +cargo test -p api-server hyper3d +cargo check -p api-server +``` + +真实 API smoke 只在本地私密环境设置 `HYPER3D_API_KEY` 后执行。提交生成任务会消耗 Hyper3D Credit,默认验证不自动调用真实生成接口。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md index c7da2561..229e834e 100644 --- a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md +++ b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md @@ -14,12 +14,12 @@ ### 1. 图片生成 -1. 拼图默认使用 APIMart `gpt-image-2` 生成图,外部请求尺寸固定为 `1:1`;`nanobanana2` 仍映射为 `gemini-3.1-flash-image-preview`。 +1. 拼图默认使用 VectorEngine `gpt-image-2-all` 生成图,外部请求尺寸固定为 `1024x1024`;前端历史 `nanobanana2` 选项只保留兼容展示,后端同样回落到 VectorEngine GPT-image-2-all,不再调用 APIMart 图片网关。 2. 历史 `original` 或空模型值只做兼容输入,不再进入 DashScope 原模型链路,统一按 `gpt-image-2` 路由。 3. 文生图和参考图生图共用同一个正方形尺寸口径,禁止一条链路仍生成竖屏或横版图。 4. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。 5. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免上游把超长 prompt 判为“请求参数不合法”。 -6. APIMart 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。 +6. VectorEngine 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。 7. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 8. 光点预扣失败属于钱包或 SpacetimeDB 服务链路错误,不得映射成 `400 BAD_REQUEST`。除余额不足返回 `409 CONFLICT` 外,其余预扣异常统一按上游/服务错误暴露,避免生成页误提示“请求参数不合法”。 @@ -47,10 +47,10 @@ ## 验收 -1. 点击拼图草稿生成或重新生成画面时,后端请求 APIMart 的 `size` 为 `1:1`,默认模型为 `gpt-image-2`。 +1. 点击拼图草稿生成或重新生成画面时,后端请求 VectorEngine 的 `size` 为 `1024x1024`,上游模型为 `gpt-image-2-all`。 2. 图片提示词包含 `1:1 正方形拼图关卡`。 3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、`避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。 -4. APIMart 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus` 和 `rawExcerpt`。 +4. VectorEngine 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus` 和 `rawExcerpt`。 5. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。 6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。 7. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。 diff --git a/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md index 6671adec..57d806f3 100644 --- a/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md +++ b/docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md @@ -56,3 +56,9 @@ 3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。 4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。 5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。 + +## 2026-05-09 发布失败提示补充 + +`publish_puzzle_work` 属于资产操作发布入口,按 `ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md` 会在发布 mutation 前预扣 `1` 枚光点。余额不足时后端返回 `409 CONFLICT`,响应 `details.message` 为 `光点余额不足`,这属于业务拒绝,不是拼图发布接口不可用。 + +结果页发布弹窗必须在用户点击发布后继续展示后端错误原因,不能只把错误写到弹窗背后的页面 banner。这样余额不足、SpacetimeDB 发布门禁或其他后端业务错误都会在当前独立发布面板中直接可见。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 017c2654..41b74eff 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,8 +5,11 @@ ## 文档列表 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 -- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品后局部运行态请求 `401` 不应扩散成全局登出的修复,覆盖请求层局部鉴权失败隔离、推荐页 embedded 运行态启动和回归测试。 +- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 +- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。 +- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖请求层局部鉴权失败隔离、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。 - [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 +- [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。 - [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。 - [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。 - [PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md](./PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md):冻结“我的”页签帮助与反馈入口的后端接入方案,覆盖 `POST /api/profile/feedback`、`profile_feedback_submission`、凭证图片 Data URL 校验和前端预览/提交边界。 @@ -20,7 +23,7 @@ - [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。 - [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。 - [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。 -- [RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md](./RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md):记录 RPG 角色主图与场景幕背景图统一迁移到 APIMart OpenAI 兼容 `gpt-image-2` 生图入口的边界、配置和验收口径。 +- [RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md](./RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md):记录 RPG 角色主图与场景幕背景图统一迁移到 `gpt-image-2` 生图入口的边界、配置和验收口径;2026-05-09 起实际上游以 VectorEngine 迁移文档为准。 - [RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md](./RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md):记录 `agent-foundation-landmark-seed-batch-1` 无搜索 Responses 请求超时的根因,并将场景骨架批次收敛为单场景生成。 - [PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md](./PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md):记录“我的”和“存档”页面在本地把 `/api/profile/*` 请求落到 Vite SPA fallback、导致 HTML 被当 JSON 解析的根因,以及 `/api/profile` 代理补齐与回归测试。 - [SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md](./SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md):记录 `WP-DEL 删除旧层与命名收口`,物理删除旧 runtime story HTTP DTO、前端 `Rpg*` alias、旧 `/api/custom-world/*` 非 runtime 前缀、Puzzle `local-next-level` 入口和 `/generated-*` 资产直读代理;生成资产读取统一走 OSS read-url 链路。 diff --git a/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md index e353756d..89e09032 100644 --- a/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md +++ b/docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md @@ -6,6 +6,8 @@ 登录成功进入平台推荐页后,推荐页会自动加载一个公开作品并启动嵌入式运行态。实际联调中出现过:作品刚加载出来,前端又瞬间回到未登录状态;停留在其他页面,或推荐页没有成功加载出作品时不会复现。 +后续复测又发现:登录成功后,从推荐页点进拼图公开作品详情并启动完整拼图运行态,也可能在开局或通关后瞬间退回未登录。两类现象的底层问题一致,都是玩法/展示层局部请求把 `401` 扩散成全局鉴权事件。 + ## 根因 推荐页首屏的作品运行态启动是后台自动副作用,不是用户主动点击的账号操作。它会触发多条受保护请求,例如: @@ -16,6 +18,10 @@ 这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。 +推荐页里还有一类更隐蔽的触发点:`ResolvedAssetImage` / `useResolvedAssetReadUrl` 在挂载时会请求 `/api/assets/read-url` 给 generated 私有图片换签。它本质上也是展示层后台请求,若按普通受保护请求处理 `401`,同样会把一次图片换签失败放大成全局掉线。 + +公开拼图作品的完整运行态还会在用户进入作品后自动发起 `startPuzzleRun`,通关后自动 `submitPuzzleLeaderboard`,点击下一关时 `advancePuzzleNextLevel`。这些请求属于当前玩法的运行态同步,失败时应该落到当前拼图错误态;它们不能清空全局 access token,也不能触发 `AuthGate` 重新 hydrate。 + ## 修复 本次把推荐页自动运行态请求定义为“卡片级后台请求”: @@ -23,14 +29,18 @@ 1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。 2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。 3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。 -4. 普通用户主动点击“启动”、Remix、发布、点赞等路径继续保留默认全局鉴权处理。 +4. generated 图片换签请求同样使用局部后台鉴权选项并跳过 refresh,失败只让当前图片为空,不触发全局登录态清理。 +5. 公开拼图作品进入完整运行态后,把本次 run 标记为 `isolated` 鉴权模式;开局、重开、排行榜提交和下一关推进都沿用局部鉴权选项。 +6. Remix、发布、点赞、账号设置、退出登录等真正账号动作继续保留默认全局鉴权处理。 ## 验证 -1. `npm run test -- src/services/apiClient.test.ts` +1. `npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts` 2. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"` -3. `npm run typecheck` -4. `npm run check:encoding` +3. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"` +4. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"` +5. `npm run typecheck` +6. `npm run check:encoding` ## 关联文件 diff --git a/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md index 276adfcf..39d220f8 100644 --- a/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md +++ b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md @@ -7,7 +7,7 @@ RPG 创作链路里有两类正式图片资产需要统一模型: 1. 角色主图候选生成。 2. 场景幕背景图生成。 -旧实现中角色主图默认使用 `wan2.7-image-pro`,场景图根据是否有参考图分别使用 DashScope 文生图与图生图模型。拼图链路已经接入 APIMart 的 OpenAI 兼容 `/images/generations`,并以 `gpt-image-2` 作为默认图片模型,因此本次 RPG 图片迁移复用同一类服务端配置与请求口径。 +旧实现中角色主图默认使用 `wan2.7-image-pro`,场景图根据是否有参考图分别使用 DashScope 文生图与图生图模型。拼图链路已经接入 GPT-image-2 图片生成,因此本次 RPG 图片迁移复用同一类服务端配置与请求口径。2026-05-09 起,GPT-image-2 图片生成上游统一迁移到 VectorEngine,具体接口以 `VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md` 为准。 ## 落地范围 @@ -26,9 +26,9 @@ RPG 创作链路里有两类正式图片资产需要统一模型: 服务端使用: ```text -POST {APIMART_BASE_URL}/images/generations -Authorization: Bearer {APIMART_API_KEY} -model = gpt-image-2 +POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations +Authorization: Bearer {VECTOR_ENGINE_API_KEY} +model = gpt-image-2-all ``` 请求体统一包含: @@ -36,16 +36,15 @@ model = gpt-image-2 1. `model` 2. `prompt` 3. `n` -4. `official_fallback = true` -5. `size` -6. 有参考图时增加 `image_urls` +4. `size` +5. 有参考图时增加 `image` 尺寸归一规则: -1. `1024*1024`、`1024x1024`、`1:1` -> `1:1` -2. `1280*720`、`1600*900`、`16:9` -> `16:9` +1. `1024*1024`、`1024x1024`、`1:1` -> `1024x1024` +2. `1280*720`、`1600*900`、`16:9` -> `1536x1024` -响应解析兼容同步 `data[].url`、`data[].b64_json` 与异步 `task_id` / `GET /tasks/{task_id}` 结构。 +响应解析同步 `data[].url` 与 `data[].b64_json`;VectorEngine GPT-image-2-all 当前不再使用 APIMart 异步 `task_id` / `GET /tasks/{task_id}` 结构。 ## 非范围 @@ -56,22 +55,22 @@ model = gpt-image-2 ## 配置 -本次复用已有 APIMart 配置: +本次复用 VectorEngine 图片配置: ```text -APIMART_BASE_URL=https://api.apimart.ai/v1 -APIMART_API_KEY=... -APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 +VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai +VECTOR_ENGINE_API_KEY=... +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 ``` -`APIMART_API_KEY` 缺失时,角色主图与场景图返回 `SERVICE_UNAVAILABLE`,`details.provider = "apimart"`。 +`VECTOR_ENGINE_API_KEY` 缺失时,角色主图与场景图返回 `SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。 ## 验收 -1. 角色主图生成请求上游 `model` 为 `gpt-image-2`,且携带 `official_fallback = true`。 -2. 场景图生成请求上游 `model` 为 `gpt-image-2`,且携带 `official_fallback = true`。 +1. 角色主图生成请求上游 `model` 为 `gpt-image-2-all`,且不携带 `official_fallback`。 +2. 场景图生成请求上游 `model` 为 `gpt-image-2-all`,且不携带 `official_fallback`。 3. 旧前端或历史草稿传 `wan2.7-image-pro` 时不会回退旧模型。 -4. 场景参考图生成仍能把参考图 Data URL 放入 `image_urls`。 +4. 场景参考图生成仍能把参考图 Data URL 放入 `image`。 5. 角色主图生成后仍执行原有 PNG 透明背景处理与 OSS 写入。 6. `cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml` 通过。 7. `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 通过。 diff --git a/docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md b/docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md new file mode 100644 index 00000000..be26b7be --- /dev/null +++ b/docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md @@ -0,0 +1,53 @@ +# SpacetimeDB publish sccache 降级处理 + +## 背景 + +Windows 本地执行 `npm run dev:rust` 或 `spacetime publish` 时,`spacetime` 会在内部调用 Cargo 构建 `server-rs/crates/spacetime-module`。因为 `server-rs/.cargo/config.toml` 配置了 `rustc-wrapper = "sccache"`,即使当前 shell 没有设置 `RUSTC_WRAPPER`,Cargo 仍会先执行 `sccache rustc -vV`。 + +当本机 sccache server 状态损坏、client/server 通信异常或版本残留不一致时,可能出现: + +```text +sccache: error: failed to execute compile +sccache: caused by: Failed to send data to or receive data from server +sccache: caused by: Failed to read response header +sccache: caused by: failed to fill whole buffer +``` + +这类错误发生在 rustc wrapper 层,不能说明 SpacetimeDB module 代码本身编译失败。 + +## 本地开发处理 + +`scripts/dev-rust-stack.sh` 的 publish 阶段保留首次正常 `sccache` 构建;如果 stderr 命中 sccache 通信或 wrapper 失败特征,则自动在同一 `--root-dir`、同一发布参数下清空本次子进程的 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER` 后重试。 + +该处理只影响本次 publish 子进程,不修改 `server-rs/.cargo/config.toml`,也不删除本地 target 缓存。 + +## 手动排障命令 + +优先确认 rustc 本身可用: + +```bash +rustc -vV +``` + +如果只想绕过本次 Cargo 构建的 sccache wrapper,可在 Git Bash 中执行: + +```bash +cd server-rs/crates/spacetime-module +RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build --target=wasm32-unknown-unknown --release +``` + +如果需要排查 sccache server 状态: + +```bash +sccache --show-stats +sccache --stop-server +sccache --start-server +``` + +`sccache --stop-server` 本身也可能因为 server 通道已损坏而失败;此时不应阻断本地开发 publish,先使用 wrapper 降级完成验证。 + +## 验证 + +1. `bash -n scripts/dev-rust-stack.sh` +2. `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown --release` +3. 重新运行 `npm run dev:rust`,看到 sccache 通信失败时脚本应打印降级提示并继续真实构建。 diff --git a/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md index 50e8e788..785c6ace 100644 --- a/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md +++ b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md @@ -39,6 +39,8 @@ spacetime --root-dir="${GENARRATIVE_SPACETIME_ROOT_DIR}" ... `spacetime start` 不再额外设置 `--data-dir`,启动前会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`;当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`。启动参数、探活和 root-dir 占用判定都使用同一个 `.spacetimedb/`。这样可以把发布包与部署机全局 `~/.spacetime` 隔离,避免后续人工 `spacetime login` 影响本地发布包。但如果旧 `.spacetimedb/` 已经由另一个身份创建,仍需要按第 4 节处理。 +本地 `npm run dev:rust` / `scripts/dev-rust-stack.sh` 也必须遵循同一条规则:`server ping`、`start` 和 `publish` 都显式使用 `server-rs/.spacetimedb/local` 作为 `--root-dir`。不要让发布命令回退到全局 CLI 登录态,否则会出现本地 root 已有目标库权限,但裸 `spacetime publish` 仍使用另一个身份发起预检查并返回 403。 + ## 4. 排查与处理 先在执行 `start.sh` 的同一台机器、同一用户下确认身份: @@ -48,6 +50,16 @@ spacetime --root-dir ./.spacetimedb login show spacetime --root-dir ./.spacetimedb list --server http://127.0.0.1:3101 ``` +本地开发栈排查时使用仓库本地 root: + +```bash +spacetime login show +spacetime --root-dir server-rs/.spacetimedb/local login show +spacetime --root-dir server-rs/.spacetimedb/local list --server http://127.0.0.1:3101 +``` + +如果裸 `spacetime login show` 的身份与 `--root-dir server-rs/.spacetimedb/local login show` 不一致,而目标库只出现在本地 root 的 `list` 结果中,说明不能使用裸 `spacetime publish`。应通过 `npm run dev:rust` 或显式追加 `--root-dir=server-rs/.spacetimedb/local` 重新发布。 + 如果目标是本地部署库,且允许清空本地数据: ```bash diff --git a/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md new file mode 100644 index 00000000..20ef258a --- /dev/null +++ b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md @@ -0,0 +1,116 @@ +# VectorEngine GPT-image-2 图片生成迁移 2026-05-09 + +## 背景 + +GPT-image-2 图片生成此前通过 APIMart OpenAI 兼容入口执行。为统一供应商网关,本次参考 VectorEngine Apifox 文档 `https://vectorengine.apifox.cn/api-448710071`,把仓库内所有 GPT-image-2 生图调用迁移到 VectorEngine,不再使用 APIMart 图片网关。 + +APIMart 仍只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态理解链路;不要把该文本链路与 GPT-image-2 图片生成配置混用。 + +## 参考接口 + +VectorEngine 正式环境基础地址来自 Apifox 项目环境: + +```text +https://api.vectorengine.ai +``` + +GPT-image-2-all 生图接口: + +```text +POST /v1/images/generations +Content-Type: application/json +Accept: application/json +Authorization: Bearer {VECTOR_ENGINE_API_KEY} +``` + +请求体: + +```json +{ + "model": "gpt-image-2-all", + "size": "1024x1024", + "n": 1, + "prompt": "生成一只猫" +} +``` + +参考图场景可按文档字段追加: + +```json +{ + "image": ["data:image/png;base64,..."] +} +``` + +响应体按同步 OpenAI Images 结构读取: + +```json +{ + "created": 1776909189, + "data": [ + { + "revised_prompt": "", + "url": "https://pro.filesystem.site/cdn/20260423/example.webp" + } + ] +} +``` + +## 尺寸映射 + +VectorEngine 文档要求使用像素尺寸,不再使用 APIMart 的比例写法: + +| 旧输入 | VectorEngine 请求值 | 用途 | +| --- | --- | --- | +| `1:1`、`1024*1024`、`1024x1024` | `1024x1024` | 拼图、方洞局部贴图、角色主形象 | +| `16:9`、`1280*720`、`1600*900`、`1536x1024` | `1536x1024` | 场景图、封面图、方洞横版背景 | +| `1024x1536` | `1024x1536` | 竖版图 | +| `2048x1152` | `1536x1024` | 开局 CG 故事板首版降为文档明确支持的横版尺寸 | + +若调用方传入其它非空尺寸,后端先透传,方便后续跟随 VectorEngine 文档扩展;空值统一回落到 `1024x1024`。 + +## 后端落点 + +1. `server-rs/crates/api-server/src/openai_image_generation.rs` + - 保留当前共享 helper 文件名与函数名,减少 RPG、方洞等调用方改动面。 + - 内部配置改读 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。 + - 请求模型固定为 `gpt-image-2-all`,不再写 `official_fallback`。 + - 请求路径改为 `/v1/images/generations`,响应直接解析 `data[].url` / `data[].b64_json`,不再轮询 `/tasks/{task_id}`。 +2. `server-rs/crates/api-server/src/puzzle.rs` + - 拼图默认 `gpt-image-2` 前端值继续兼容,但上游请求模型统一映射到 `gpt-image-2-all`。 + - `nanobanana2` / `gemini-3.1-flash-image-preview` 不再走 APIMart;当前阶段统一回落到 VectorEngine GPT-image-2-all,避免保留旧图片网关。 + - 错误 `details.provider` 改为 `vector-engine`。 +3. `.codex/skills/gpt-image-2-apimart/` + - 目录名暂不强制迁移,避免本地插件索引漂移;Skill 文案与脚本行为改为 VectorEngine。 + +## 环境变量 + +```text +VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai +VECTOR_ENGINE_API_KEY= +VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 +``` + +说明: + +1. GPT-image-2 图片生成不读取 `APIMART_BASE_URL`、`APIMART_API_KEY` 或 `APIMART_IMAGE_REQUEST_TIMEOUT_MS`。 +2. `VECTOR_ENGINE_BASE_URL` 仍允许部署环境覆盖,不在代码中绑定私有网关。 +3. `VECTOR_ENGINE_API_KEY` 只能进入本地或生产私密环境文件,不提交到 Git。 + +## 非范围 + +1. 不迁移创意 Agent 的 APIMart `gpt-5` Responses 链路。 +2. 不改变 SpacetimeDB 表结构、migration 或 bindings。 +3. 不改前端 UI 文案和模型选择控件展示。 +4. 不新增新的图片资产表或图片代理路由。 + +## 验收 + +1. 所有 GPT-image-2 生图请求都走 `POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations`。 +2. 请求体 `model = gpt-image-2-all`,尺寸为 VectorEngine 支持的像素尺寸。 +3. 请求体不再包含 `official_fallback`。 +4. 参考图字段使用 `image`,不再使用 APIMart 的 `image_urls`。 +5. 缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时返回 `503 SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。 +6. 上游错误映射为 `502 UPSTREAM_ERROR`,保留 `upstreamStatus`、业务 message 和截断后的 raw excerpt。 +7. 运行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`。 +8. 后端改动后使用 `npm run api-server` 重启,并确认 `/healthz`。 diff --git a/media/social-media-group/wechat.png b/media/social-media-group/wechat.png index 666de689..96874f48 100644 Binary files a/media/social-media-group/wechat.png and b/media/social-media-group/wechat.png differ diff --git a/packages/shared/src/contracts/hyper3d.ts b/packages/shared/src/contracts/hyper3d.ts new file mode 100644 index 00000000..f0ac8326 --- /dev/null +++ b/packages/shared/src/contracts/hyper3d.ts @@ -0,0 +1,75 @@ +export type Hyper3dGenerationMode = 'text-to-model' | 'image-to-model'; + +export type Hyper3dTextToModelRequest = { + prompt: string; + negativePrompt?: string | null; + seed?: number | null; + geometryFileFormat?: string | null; + material?: string | null; + quality?: string | null; + meshMode?: string | null; + addons?: string[]; + bboxCondition?: number[] | null; + previewRender?: boolean | null; +}; + +export type Hyper3dImageToModelRequest = { + imageDataUrls?: string[]; + imageUrls?: string[]; + prompt?: string | null; + conditionMode?: string | null; + seed?: number | null; + geometryFileFormat?: string | null; + material?: string | null; + quality?: string | null; + meshMode?: string | null; + addons?: string[]; + bboxCondition?: number[] | null; + previewRender?: boolean | null; +}; + +export type Hyper3dTaskSubmitResponse = { + ok: boolean; + provider: string; + mode: Hyper3dGenerationMode; + taskUuid: string; + subscriptionKey: string; + jobUuids: string[]; + message?: string | null; + tier: string; +}; + +export type Hyper3dTaskStatusRequest = { + subscriptionKey: string; +}; + +export type Hyper3dJobStatusPayload = { + uuid?: string | null; + status: string; + progress?: number | null; + message?: string | null; +}; + +export type Hyper3dTaskStatusResponse = { + ok: boolean; + provider: string; + status: string; + jobs: Hyper3dJobStatusPayload[]; + raw: unknown; +}; + +export type Hyper3dDownloadRequest = { + taskUuid: string; +}; + +export type Hyper3dDownloadFilePayload = { + name: string; + url: string; +}; + +export type Hyper3dDownloadResponse = { + ok: boolean; + provider: string; + files: Hyper3dDownloadFilePayload[]; + raw: unknown; +}; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 29c3b604..9ba7bd91 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,3 +1,4 @@ export type * from './creativeAgent'; +export type * from './hyper3d'; export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f679fc55..5012b0bd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from './contracts/common'; export type * from './contracts/creationAgentDocumentInput'; export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; +export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; export * from './contracts/match3dWorks'; diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index e693e613..7077aac7 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -195,7 +195,7 @@ is_spacetime_ready() { local server="$1" local output - if output="$(spacetime server ping "${server}" 2>&1)" && + if output="$(spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${server}" 2>&1)" && [[ "${output}" == *"Server is online:"* ]]; then return 0 fi @@ -345,6 +345,48 @@ prepare_migration_bootstrap_secret() { echo "[dev:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}" } +is_sccache_wrapper_failure_log() { + local log_file="$1" + + grep -Eiq \ + 'sccache: error|could not execute process.*sccache|Failed to send data to or receive data from server|Mismatch of client/server versions|Failed to read response header|failed to fill whole buffer' \ + "${log_file}" +} + +run_spacetime_publish_with_sccache_fallback() { + local root_dir="$1" + shift + + local publish_log + publish_log="$(mktemp -t genarrative-spacetime-publish.XXXXXX.log)" + + set +e + spacetime --root-dir="${root_dir}" "$@" 2> >(tee "${publish_log}" >&2) + local publish_status="$?" + set -e + + if [[ "${publish_status}" -eq 0 ]]; then + rm -f "${publish_log}" + return 0 + fi + + if ! is_sccache_wrapper_failure_log "${publish_log}"; then + rm -f "${publish_log}" + return "${publish_status}" + fi + + echo "[dev:rust] 检测到 sccache wrapper 通信异常,改用 rustc 直连重试 SpacetimeDB 发布。" + echo "[dev:rust] 这只影响本次 publish;项目级 sccache 配置不会被修改。" + + set +e + RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= spacetime --root-dir="${root_dir}" "$@" + publish_status="$?" + set -e + + rm -f "${publish_log}" + return "${publish_status}" +} + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" SERVER_RS_DIR="${REPO_ROOT}/server-rs" @@ -564,6 +606,7 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then # 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口; # 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。 printf '\n' | spacetime \ + --root-dir="${SPACETIME_ROOT_DIR}" \ start \ --data-dir "${SPACETIME_DATA_DIR}" \ --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ @@ -601,7 +644,9 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then cd "${SERVER_RS_DIR}" # spacetime publish 会在内部调用 Cargo;从 server-rs 目录执行,确保读取 # server-rs/.cargo/config.toml 中的 sccache/linker 配置,并复用同一套 target 缓存。 - spacetime "${PUBLISH_ARGS[@]}" + # Windows 本地 sccache server 偶发通信异常时,保留 root-dir/target 缓存路径, + # 仅对重试子进程清空 Cargo wrapper,避免可选缓存工具阻断真实构建。 + run_spacetime_publish_with_sccache_fallback "${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}" ) fi diff --git a/scripts/dev-web-rust.mjs b/scripts/dev-web-rust.mjs index 98d1ed10..681f61c8 100644 --- a/scripts/dev-web-rust.mjs +++ b/scripts/dev-web-rust.mjs @@ -37,14 +37,30 @@ loadEnvFile(resolve(repoRoot, '.env'), fileEnv); loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv); loadEnvFile(resolve(repoRoot, '.env.secrets.local'), fileEnv); -function buildTargetCandidates() { +function resolveConfiguredTarget() { + if (fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET) { + return fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET; + } + + if (fileEnv.RUST_SERVER_TARGET) { + return fileEnv.RUST_SERVER_TARGET; + } + + if (fileEnv.GENARRATIVE_API_TARGET) { + return fileEnv.GENARRATIVE_API_TARGET; + } + + if (fileEnv.GENARRATIVE_API_PORT) { + return `http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT}`; + } + + return ''; +} + +function buildFallbackCandidates() { const candidates = [ - fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET, - fileEnv.RUST_SERVER_TARGET, - fileEnv.GENARRATIVE_API_TARGET, - `http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`, - 'http://127.0.0.1:8082', 'http://127.0.0.1:3100', + 'http://127.0.0.1:8082', ].filter(Boolean); return Array.from(new Set(candidates)); @@ -70,39 +86,30 @@ async function isTargetReachable(target) { } async function resolveRuntimeTarget() { - const candidates = buildTargetCandidates(); - const reachableTargets = []; + const configuredTarget = resolveConfiguredTarget(); - for (const target of candidates) { - if (await isTargetReachable(target)) { - reachableTargets.push(target); - if ( - target === fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET || - target === fileEnv.RUST_SERVER_TARGET || - target === fileEnv.GENARRATIVE_API_TARGET - ) { - return { - target, - fallbackUsed: false, - }; - } - } - } - - if (reachableTargets.length > 0) { + if (configuredTarget) { return { - target: reachableTargets[0], - fallbackUsed: true, + target: configuredTarget, + fallbackUsed: false, + targetUnavailable: !(await isTargetReachable(configuredTarget)), }; } + for (const target of buildFallbackCandidates()) { + if (await isTargetReachable(target)) { + return { + target, + fallbackUsed: true, + targetUnavailable: false, + }; + } + } + return { - target: - fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET || - fileEnv.RUST_SERVER_TARGET || - fileEnv.GENARRATIVE_API_TARGET || - `http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`, + target: 'http://127.0.0.1:3100', fallbackUsed: false, + targetUnavailable: true, }; } @@ -113,6 +120,12 @@ if (runtimeTarget.fallbackUsed) { ); } +if (runtimeTarget.targetUnavailable) { + console.warn( + `[dev:web] Rust target 当前不可用: ${runtimeTarget.target},请先启动 api-server。`, + ); +} + const mergedEnv = { ...fileEnv, RUST_SERVER_TARGET: runtimeTarget.target, diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 6dc372f8..acaf6c2b 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -10,7 +10,7 @@ axum = { workspace = true, features = ["ws"] } base64 = { workspace = true } dotenvy = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } -reqwest = { workspace = true, features = ["json", "rustls-tls"] } +reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } webp = { workspace = true } module-ai = { workspace = true } module-assets = { workspace = true, features = ["server-service"] } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 0170c726..9ba8e7c5 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -74,6 +74,10 @@ use crate::{ }, error_middleware::normalize_error_response, health::health_check, + hyper3d_generation::{ + get_hyper3d_downloads, get_hyper3d_task_status, submit_hyper3d_image_to_model, + submit_hyper3d_text_to_model, + }, llm::proxy_llm_chat_completions, login_options::auth_login_options, logout::logout, @@ -166,6 +170,7 @@ use crate::{ const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024; +const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { @@ -550,6 +555,38 @@ pub fn build_router(state: AppState) -> Router { post(resolve_role_asset_workflow).put(put_role_asset_workflow), ) .route("/api/assets/read-url", get(get_asset_read_url)) + .route( + "/api/assets/hyper3d/text-to-model", + post(submit_hyper3d_text_to_model).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/image-to-model", + post(submit_hyper3d_image_to_model) + .layer(DefaultBodyLimit::max( + HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/status", + post(get_hyper3d_task_status).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/assets/hyper3d/download", + post(get_hyper3d_downloads).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/assets/history", get(get_asset_history).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6bfeca45..d8e42168 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -96,10 +96,13 @@ pub struct AppConfig { pub dashscope_image_request_timeout_ms: u64, pub apimart_base_url: String, pub apimart_api_key: Option, - pub apimart_image_request_timeout_ms: u64, pub vector_engine_base_url: String, pub vector_engine_api_key: Option, + pub vector_engine_image_request_timeout_ms: u64, pub vector_engine_audio_request_timeout_ms: u64, + pub hyper3d_base_url: String, + pub hyper3d_api_key: Option, + pub hyper3d_model_request_timeout_ms: u64, pub volcengine_speech_api_key: Option, pub volcengine_speech_app_id: Option, pub volcengine_speech_access_key: Option, @@ -203,10 +206,13 @@ impl Default for AppConfig { dashscope_image_request_timeout_ms: 150_000, apimart_base_url: String::new(), apimart_api_key: None, - apimart_image_request_timeout_ms: 180_000, vector_engine_base_url: String::new(), vector_engine_api_key: None, + vector_engine_image_request_timeout_ms: 180_000, vector_engine_audio_request_timeout_ms: 180_000, + hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(), + hyper3d_api_key: None, + hyper3d_model_request_timeout_ms: 180_000, volcengine_speech_api_key: None, volcengine_speech_app_id: None, volcengine_speech_access_key: None, @@ -567,12 +573,6 @@ impl AppConfig { config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); - if let Some(apimart_image_request_timeout_ms) = - read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) - { - config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; - } - if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"]) { config.vector_engine_base_url = vector_engine_base_url; @@ -580,12 +580,33 @@ impl AppConfig { config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]); + if let Some(vector_engine_image_request_timeout_ms) = + read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"]) + { + config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms; + } + if let Some(vector_engine_audio_request_timeout_ms) = read_first_positive_u64_env(&["VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS"]) { config.vector_engine_audio_request_timeout_ms = vector_engine_audio_request_timeout_ms; } + if let Some(hyper3d_base_url) = + read_first_non_empty_env(&["HYPER3D_BASE_URL", "RODIN_BASE_URL"]) + { + config.hyper3d_base_url = hyper3d_base_url; + } + + config.hyper3d_api_key = read_first_non_empty_env(&["HYPER3D_API_KEY", "RODIN_API_KEY"]); + + if let Some(hyper3d_model_request_timeout_ms) = read_first_positive_u64_env(&[ + "HYPER3D_MODEL_REQUEST_TIMEOUT_MS", + "RODIN_MODEL_REQUEST_TIMEOUT_MS", + ]) { + config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms; + } + config.volcengine_speech_api_key = read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]); config.volcengine_speech_app_id = @@ -910,6 +931,7 @@ mod tests { assert!(config.apimart_base_url.is_empty()); assert!(config.vector_engine_base_url.is_empty()); assert!(config.ark_character_video_base_url.is_empty()); + assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2"); assert!(config.ark_character_video_model.is_empty()); assert!(config.dashscope_scene_image_model.is_empty()); assert!(config.dashscope_reference_image_model.is_empty()); @@ -938,6 +960,8 @@ mod tests { std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_BASE_URL"); + std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); + std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); @@ -949,8 +973,10 @@ mod tests { "https://llm.internal.example/v1", ); std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model"); - std::env::set_var("APIMART_BASE_URL", "https://image.internal.example/v1"); - std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://audio.internal.example"); + std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1"); + std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example"); + std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000"); + std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2"); std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model"); std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model"); std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-model"); @@ -965,10 +991,18 @@ mod tests { assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible); assert_eq!(config.llm_base_url, "https://llm.internal.example/v1"); assert_eq!(config.llm_model, "internal-text-model"); - assert_eq!(config.apimart_base_url, "https://image.internal.example/v1"); + assert_eq!( + config.apimart_base_url, + "https://responses.internal.example/v1" + ); assert_eq!( config.vector_engine_base_url, - "https://audio.internal.example" + "https://vector.internal.example" + ); + assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000); + assert_eq!( + config.hyper3d_base_url, + "https://model.internal.example/api/v2" ); assert_eq!(config.dashscope_scene_image_model, "scene-model"); assert_eq!(config.dashscope_reference_image_model, "reference-model"); @@ -985,6 +1019,8 @@ mod tests { std::env::remove_var("GENARRATIVE_LLM_MODEL"); std::env::remove_var("APIMART_BASE_URL"); std::env::remove_var("VECTOR_ENGINE_BASE_URL"); + std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"); + std::env::remove_var("HYPER3D_BASE_URL"); std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL"); std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL"); diff --git a/server-rs/crates/api-server/src/hyper3d_generation.rs b/server-rs/crates/api-server/src/hyper3d_generation.rs new file mode 100644 index 00000000..3824f00c --- /dev/null +++ b/server-rs/crates/api-server/src/hyper3d_generation.rs @@ -0,0 +1,1052 @@ +use std::time::Duration; + +use axum::{ + Json, + extract::{State, rejection::JsonRejection}, + http::StatusCode, + response::Response, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::{header, multipart}; +use serde_json::{Value, json}; +use shared_contracts::hyper3d as contract; + +use crate::{ + api_response::json_success_body, http_error::AppError, request_context::RequestContext, + state::AppState, +}; + +const HYPER3D_PROVIDER: &str = "hyper3d-rodin"; +const RODIN_GEN2_TIER: &str = "Gen-2"; +const DEFAULT_GEOMETRY_FILE_FORMAT: &str = "glb"; +const DEFAULT_MATERIAL: &str = "PBR"; +const DEFAULT_QUALITY: &str = "medium"; +const DEFAULT_MESH_MODE: &str = "Quad"; +const DEFAULT_CONDITION_MODE: &str = "concat"; +const MAX_PROMPT_CHARS: usize = 2_000; +const MAX_NEGATIVE_PROMPT_CHARS: usize = 1_000; +const MAX_IMAGE_COUNT: usize = 5; +const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Clone, Debug)] +struct Hyper3dSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +struct DecodedImageDataUrl { + bytes: Vec, + mime_type: String, + file_name: String, +} + +#[derive(Clone, Debug)] +struct SubmitOptions { + seed: Option, + geometry_file_format: String, + material: String, + quality: String, + mesh_mode: String, + addons: Vec, + bbox_condition: Option>, + preview_render: bool, +} + +pub async fn submit_hyper3d_text_to_model( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + submit_text_to_model(&state, payload) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +pub async fn submit_hyper3d_image_to_model( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + submit_image_to_model(&state, payload) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +pub async fn get_hyper3d_task_status( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + query_task_status(&state, payload) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +pub async fn get_hyper3d_downloads( + State(state): State, + axum::extract::Extension(request_context): axum::extract::Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + query_downloads(&state, payload) + .await + .map(|payload| json_success_body(Some(&request_context), payload)) + .map_err(|error| error.into_response_with_context(Some(&request_context))) +} + +async fn submit_text_to_model( + state: &AppState, + payload: contract::Hyper3dTextToModelRequest, +) -> Result { + let settings = require_hyper3d_settings(state)?; + let http_client = build_hyper3d_http_client(&settings)?; + let prompt = normalize_required_text(&payload.prompt, "prompt", MAX_PROMPT_CHARS)?; + let options = SubmitOptions::from_text_request(&payload)?; + let mut form = multipart::Form::new() + .text("tier", RODIN_GEN2_TIER.to_string()) + .text("prompt", prompt); + form = append_common_submit_fields(form, &options)?; + if let Some(negative_prompt) = normalize_optional_limited_text( + payload.negative_prompt.as_deref(), + MAX_NEGATIVE_PROMPT_CHARS, + )? { + form = form.text("negative_prompt", negative_prompt); + } + + let response = post_hyper3d_multipart( + &http_client, + &settings, + "/rodin", + form, + "提交 Hyper3D 文生模型任务失败", + ) + .await?; + + Ok(build_submit_response( + contract::Hyper3dGenerationMode::TextToModel, + response, + )?) +} + +async fn submit_image_to_model( + state: &AppState, + payload: contract::Hyper3dImageToModelRequest, +) -> Result { + let settings = require_hyper3d_settings(state)?; + let http_client = build_hyper3d_http_client(&settings)?; + let options = SubmitOptions::from_image_request(&payload)?; + let mut form = multipart::Form::new().text("tier", RODIN_GEN2_TIER.to_string()); + form = append_common_submit_fields(form, &options)?; + let condition_mode = normalize_enum( + payload.condition_mode.as_deref(), + DEFAULT_CONDITION_MODE, + &["concat", "fuse"], + "conditionMode", + )?; + form = form.text("condition_mode", condition_mode); + if let Some(prompt) = + normalize_optional_limited_text(payload.prompt.as_deref(), MAX_PROMPT_CHARS)? + { + form = form.text("prompt", prompt); + } + for image_url in payload + .image_urls + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + form = form.text("image_urls", image_url.to_string()); + } + for image in decode_image_data_urls(&payload.image_data_urls)? { + let part = multipart::Part::bytes(image.bytes) + .file_name(image.file_name) + .mime_str(&image.mime_type) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": format!("构造图生模型图片字段失败:{error}"), + })) + })?; + form = form.part("images", part); + } + + if payload.image_data_urls.is_empty() && payload.image_urls.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "imageDataUrls", + "message": "图生模型至少需要一张参考图", + })), + ); + } + if payload.image_data_urls.len() + payload.image_urls.len() > MAX_IMAGE_COUNT { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "imageDataUrls", + "message": format!("图生模型最多支持 {} 张参考图", MAX_IMAGE_COUNT), + })), + ); + } + + let response = post_hyper3d_multipart( + &http_client, + &settings, + "/rodin", + form, + "提交 Hyper3D 图生模型任务失败", + ) + .await?; + + Ok(build_submit_response( + contract::Hyper3dGenerationMode::ImageToModel, + response, + )?) +} + +async fn query_task_status( + state: &AppState, + payload: contract::Hyper3dTaskStatusRequest, +) -> Result { + let settings = require_hyper3d_settings(state)?; + let http_client = build_hyper3d_http_client(&settings)?; + let subscription_key = + normalize_required_text(&payload.subscription_key, "subscriptionKey", 256)?; + let response = post_hyper3d_json( + &http_client, + &settings, + "/status", + json!({ "subscription_key": subscription_key }), + "查询 Hyper3D 模型任务状态失败", + ) + .await?; + + let jobs = extract_job_statuses(&response); + let status = normalize_task_status( + find_first_string_by_key(&response, "status") + .or_else(|| jobs.first().map(|job| job.status.clone())) + .as_deref() + .unwrap_or("unknown"), + ); + + Ok(contract::Hyper3dTaskStatusResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + status, + jobs, + raw: response, + }) +} + +async fn query_downloads( + state: &AppState, + payload: contract::Hyper3dDownloadRequest, +) -> Result { + let settings = require_hyper3d_settings(state)?; + let http_client = build_hyper3d_http_client(&settings)?; + let task_uuid = normalize_required_text(&payload.task_uuid, "taskUuid", 256)?; + let response = post_hyper3d_json( + &http_client, + &settings, + "/download", + json!({ "task_uuid": task_uuid }), + "获取 Hyper3D 模型下载列表失败", + ) + .await?; + + Ok(contract::Hyper3dDownloadResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + files: extract_download_files(&response), + raw: response, + }) +} + +impl SubmitOptions { + fn from_text_request(payload: &contract::Hyper3dTextToModelRequest) -> Result { + Self::new( + payload.seed, + payload.geometry_file_format.as_deref(), + payload.material.as_deref(), + payload.quality.as_deref(), + payload.mesh_mode.as_deref(), + payload.addons.clone(), + payload.bbox_condition.clone(), + payload.preview_render, + ) + } + + fn from_image_request( + payload: &contract::Hyper3dImageToModelRequest, + ) -> Result { + Self::new( + payload.seed, + payload.geometry_file_format.as_deref(), + payload.material.as_deref(), + payload.quality.as_deref(), + payload.mesh_mode.as_deref(), + payload.addons.clone(), + payload.bbox_condition.clone(), + payload.preview_render, + ) + } + + #[allow(clippy::too_many_arguments)] + fn new( + seed: Option, + geometry_file_format: Option<&str>, + material: Option<&str>, + quality: Option<&str>, + mesh_mode: Option<&str>, + addons: Vec, + bbox_condition: Option>, + preview_render: Option, + ) -> Result { + Ok(Self { + seed, + geometry_file_format: normalize_enum( + geometry_file_format, + DEFAULT_GEOMETRY_FILE_FORMAT, + &["glb", "usdz", "fbx", "obj", "stl"], + "geometryFileFormat", + )?, + material: normalize_enum( + material, + DEFAULT_MATERIAL, + &["PBR", "Shaded", "All"], + "material", + )?, + quality: normalize_enum( + quality, + DEFAULT_QUALITY, + &["high", "medium", "low", "extra-low"], + "quality", + )?, + mesh_mode: normalize_enum(mesh_mode, DEFAULT_MESH_MODE, &["Quad", "Raw"], "meshMode")?, + addons: normalize_addons(addons)?, + bbox_condition: normalize_bbox_condition(bbox_condition)?, + preview_render: preview_render.unwrap_or(true), + }) + } +} + +fn append_common_submit_fields( + mut form: multipart::Form, + options: &SubmitOptions, +) -> Result { + form = form + .text( + "geometry_file_format", + options.geometry_file_format.to_string(), + ) + .text("material", options.material.to_string()) + .text("quality", options.quality.to_string()) + .text("mesh_mode", options.mesh_mode.to_string()) + .text("preview_render", options.preview_render.to_string()); + if let Some(seed) = options.seed { + form = form.text("seed", seed.to_string()); + } + for addon in &options.addons { + form = form.text("addons", addon.to_string()); + } + if let Some(bbox_condition) = &options.bbox_condition { + form = form.text( + "bbox_condition", + serde_json::to_string(bbox_condition).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "bboxCondition", + "message": format!("bboxCondition 序列化失败:{error}"), + })) + })?, + ); + } + + Ok(form) +} + +fn require_hyper3d_settings(state: &AppState) -> Result { + let base_url = state.config.hyper3d_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "reason": "HYPER3D_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .hyper3d_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "reason": "HYPER3D_API_KEY 未配置", + })) + })?; + + Ok(Hyper3dSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.hyper3d_model_request_timeout_ms.max(1), + }) +} + +fn build_hyper3d_http_client(settings: &Hyper3dSettings) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": format!("构造 Hyper3D HTTP 客户端失败:{error}"), + })) + }) +} + +async fn post_hyper3d_multipart( + http_client: &reqwest::Client, + settings: &Hyper3dSettings, + path: &str, + form: multipart::Form, + failure_context: &str, +) -> Result { + let response = http_client + .post(format!("{}{}", settings.base_url, path)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; + parse_hyper3d_response(response, failure_context).await +} + +async fn post_hyper3d_json( + http_client: &reqwest::Client, + settings: &Hyper3dSettings, + path: &str, + body: Value, + failure_context: &str, +) -> Result { + let response = http_client + .post(format!("{}{}", settings.base_url, path)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&body) + .send() + .await + .map_err(|error| hyper3d_bad_gateway(format!("{failure_context}:{error}")))?; + parse_hyper3d_response(response, failure_context).await +} + +async fn parse_hyper3d_response( + response: reqwest::Response, + failure_context: &str, +) -> Result { + let status = response.status(); + let raw_text = response.text().await.map_err(|error| { + hyper3d_bad_gateway(format!("{failure_context}:读取上游响应失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": parse_api_error_message(&raw_text, failure_context), + "status": status.as_u16(), + "rawExcerpt": truncate_raw(&raw_text), + })), + ); + } + + serde_json::from_str::(&raw_text).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": format!("{failure_context}:解析上游 JSON 失败:{error}"), + "rawExcerpt": truncate_raw(&raw_text), + })) + }) +} + +fn build_submit_response( + mode: contract::Hyper3dGenerationMode, + response: Value, +) -> Result { + let task_uuid = find_root_string_by_keys(&response, &["uuid", "task_uuid", "taskUuid"]) + .or_else(|| find_first_string_by_keys(&response, &["task_uuid", "taskUuid"])) + .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回任务 uuid"))?; + let subscription_key = + find_root_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) + .or_else(|| { + find_first_string_by_keys(&response, &["subscription_key", "subscriptionKey"]) + }) + .ok_or_else(|| hyper3d_bad_gateway("Hyper3D 已响应,但未返回 subscription_key"))?; + let job_uuids = extract_job_uuids(&response); + let message = find_first_string_by_keys(&response, &["message", "detail"]); + + Ok(contract::Hyper3dTaskSubmitResponse { + ok: true, + provider: HYPER3D_PROVIDER.to_string(), + mode, + task_uuid, + subscription_key, + job_uuids, + message, + tier: RODIN_GEN2_TIER.to_string(), + }) +} + +fn extract_job_statuses(payload: &Value) -> Vec { + let Some(array) = find_first_array_by_keys(payload, &["jobs", "tasks"]) else { + return Vec::new(); + }; + + array + .iter() + .filter_map(|value| { + let status = find_first_string_by_keys(value, &["status", "state"]) + .map(|value| normalize_task_status(&value))?; + Some(contract::Hyper3dJobStatusPayload { + uuid: find_first_string_by_keys(value, &["uuid", "task_uuid", "taskUuid"]), + progress: find_first_f64_by_keys(value, &["progress", "percentage"]) + .map(|value| value as f32), + message: find_first_string_by_keys(value, &["message", "detail", "error"]), + status, + }) + }) + .collect() +} + +fn extract_job_uuids(payload: &Value) -> Vec { + let mut job_uuids = Vec::new(); + if let Some(jobs) = find_first_array_by_keys(payload, &["jobs"]) { + for job in jobs { + if let Some(uuid) = find_first_string_by_keys(job, &["uuid", "task_uuid", "taskUuid"]) + && !job_uuids.contains(&uuid) + { + job_uuids.push(uuid); + } + } + } + for uuid in collect_strings_by_keys(payload, &["job_uuids", "jobUuids", "uuids"]) { + if !job_uuids.contains(&uuid) { + job_uuids.push(uuid); + } + } + job_uuids +} + +fn extract_download_files(payload: &Value) -> Vec { + let mut files = Vec::new(); + collect_download_files(payload, &mut files); + let mut deduped = Vec::new(); + for file in files { + if !deduped + .iter() + .any(|entry: &contract::Hyper3dDownloadFilePayload| entry.url == file.url) + { + deduped.push(file); + } + } + deduped +} + +fn collect_download_files(value: &Value, output: &mut Vec) { + match value { + Value::Object(object) => { + let maybe_url = object + .get("url") + .or_else(|| object.get("download_url")) + .or_else(|| object.get("downloadUrl")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with("http://") || value.starts_with("https://")); + if let Some(url) = maybe_url { + let name = object + .get("name") + .or_else(|| object.get("file_name")) + .or_else(|| object.get("filename")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("model") + .to_string(); + output.push(contract::Hyper3dDownloadFilePayload { + name, + url: url.to_string(), + }); + } + for nested in object.values() { + collect_download_files(nested, output); + } + } + Value::Array(items) => { + for item in items { + collect_download_files(item, output); + } + } + _ => {} + } +} + +fn decode_image_data_urls(values: &[String]) -> Result, AppError> { + values + .iter() + .enumerate() + .map(|(index, value)| decode_image_data_url(value, index + 1)) + .collect() +} + +fn decode_image_data_url(value: &str, index: usize) -> Result { + let value = value.trim(); + let Some((metadata, encoded)) = value.split_once(',') else { + return Err(invalid_image_data_url("参考图必须是 data URL")); + }; + if !metadata.starts_with("data:image/") || !metadata.ends_with(";base64") { + return Err(invalid_image_data_url( + "参考图只支持 image/png、image/jpeg 或 image/webp 的 base64 data URL", + )); + } + let mime_type = metadata + .trim_start_matches("data:") + .trim_end_matches(";base64") + .to_string(); + let extension = match mime_type.as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + _ => { + return Err(invalid_image_data_url( + "参考图只支持 image/png、image/jpeg 或 image/webp", + )); + } + }; + let bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|_| invalid_image_data_url("参考图 base64 解码失败"))?; + if bytes.is_empty() || bytes.len() > MAX_IMAGE_BYTES { + return Err(invalid_image_data_url("参考图为空或超过 10MB")); + } + + Ok(DecodedImageDataUrl { + bytes, + mime_type, + file_name: format!("reference-{index:02}.{extension}"), + }) +} + +fn invalid_image_data_url(message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "imageDataUrls", + "message": message, + })) +} + +fn normalize_required_text( + value: &str, + field: &'static str, + max_chars: usize, +) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": field, + "message": format!("{field} 不能为空"), + })), + ); + } + if normalized.chars().count() > max_chars { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": field, + "message": format!("{field} 超过 {} 字符", max_chars), + })), + ); + } + Ok(normalized) +} + +fn normalize_optional_limited_text( + value: Option<&str>, + max_chars: usize, +) -> Result, AppError> { + let Some(normalized) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if normalized.chars().count() > max_chars { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": format!("文本超过 {} 字符", max_chars), + })), + ); + } + Ok(Some(normalized.to_string())) +} + +fn normalize_enum( + value: Option<&str>, + default_value: &str, + allowed_values: &[&str], + field: &'static str, +) -> Result { + let value = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default_value); + if let Some(allowed) = allowed_values + .iter() + .find(|allowed| allowed.eq_ignore_ascii_case(value)) + { + return Ok((*allowed).to_string()); + } + + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": field, + "message": format!("{} 取值非法", field), + "allowed": allowed_values, + })), + ) +} + +fn normalize_addons(values: Vec) -> Result, AppError> { + let mut addons = Vec::new(); + for value in values { + let value = value.trim(); + if value.is_empty() { + continue; + } + if value != "HighPack" { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "addons", + "message": "addons 首版只支持 HighPack", + })), + ); + } + if !addons.iter().any(|addon| addon == value) { + addons.push(value.to_string()); + } + } + Ok(addons) +} + +fn normalize_bbox_condition(value: Option>) -> Result>, AppError> { + let Some(value) = value else { + return Ok(None); + }; + if value.len() != 3 || value.iter().any(|item| !item.is_finite() || *item <= 0.0) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "field": "bboxCondition", + "message": "bboxCondition 必须包含 3 个正数", + })), + ); + } + Ok(Some(value)) +} + +fn normalize_task_status(status: &str) -> String { + match status.trim().to_ascii_lowercase().as_str() { + "waiting" | "pending" | "queued" => "waiting".to_string(), + "generating" | "running" | "processing" => "generating".to_string(), + "done" | "finished" | "completed" | "success" | "succeeded" => "done".to_string(), + "failed" | "error" | "canceled" | "cancelled" => "failed".to_string(), + _ => "unknown".to_string(), + } +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for key in ["message", "detail", "error"] { + if let Some(message) = find_first_string_by_key(&parsed, key) + && !message.trim().is_empty() + { + return message; + } + } + } + raw_text + .trim() + .chars() + .take(240) + .collect::() + .trim() + .to_string() + .chars() + .next() + .map(|_| raw_text.trim().chars().take(240).collect()) + .unwrap_or_else(|| fallback_message.to_string()) +} + +fn find_first_array_by_keys<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) + && let Some(array) = value.as_array() + { + return Some(array); + } + if let Some(found) = find_first_array_by_keys(value, keys) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_array_by_keys(item, keys)), + _ => None, + } +} + +fn find_first_string_by_keys(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| find_first_string_by_key(value, key)) +} + +fn find_root_string_by_keys(value: &Value, keys: &[&str]) -> Option { + let object = value.as_object()?; + for key in keys { + if let Some(text) = object + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(key)) + .and_then(|(_, value)| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(text.to_string()); + } + } + None +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if key.eq_ignore_ascii_case(target_key) + && let Some(text) = value.as_str() + { + return Some(text.trim().to_string()); + } + if let Some(found) = find_first_string_by_key(value, target_key) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_string_by_key(item, target_key)), + _ => None, + } +} + +fn find_first_f64_by_keys(value: &Value, keys: &[&str]) -> Option { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) + && let Some(number) = value.as_f64() + { + return Some(number); + } + if let Some(found) = find_first_f64_by_keys(value, keys) { + return Some(found); + } + } + None + } + Value::Array(items) => items + .iter() + .find_map(|item| find_first_f64_by_keys(item, keys)), + _ => None, + } +} + +fn collect_strings_by_keys(value: &Value, keys: &[&str]) -> Vec { + let mut results = Vec::new(); + collect_strings(value, keys, &mut results); + let mut deduped = Vec::new(); + for result in results { + if !deduped.contains(&result) { + deduped.push(result); + } + } + deduped +} + +fn collect_strings(value: &Value, keys: &[&str], output: &mut Vec) { + match value { + Value::Object(object) => { + for (key, value) in object { + if keys.iter().any(|target| key.eq_ignore_ascii_case(target)) { + match value { + Value::String(text) if !text.trim().is_empty() => { + output.push(text.trim().to_string()); + } + Value::Array(items) => { + for item in items { + if let Some(text) = item.as_str().map(str::trim) + && !text.is_empty() + { + output.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings(value, keys, output); + } + } + Value::Array(items) => { + for item in items { + collect_strings(item, keys, output); + } + } + _ => {} + } +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} + +fn hyper3d_bad_gateway(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": HYPER3D_PROVIDER, + "message": message.into(), + })) +} + +fn parse_json_payload( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result, Response> { + payload.map_err(|rejection| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("请求体 JSON 不合法:{rejection}")) + .into_response_with_context(Some(request_context)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_and_defaults_submit_options() { + let payload = contract::Hyper3dTextToModelRequest { + prompt: "宝箱".to_string(), + negative_prompt: None, + seed: Some(7), + geometry_file_format: None, + material: None, + quality: None, + mesh_mode: None, + addons: vec!["HighPack".to_string()], + bbox_condition: Some(vec![1.0, 2.0, 3.0]), + preview_render: None, + }; + + let options = SubmitOptions::from_text_request(&payload).expect("options should build"); + + assert_eq!(options.geometry_file_format, "glb"); + assert_eq!(options.material, "PBR"); + assert_eq!(options.quality, "medium"); + assert_eq!(options.mesh_mode, "Quad"); + assert_eq!(options.addons, vec!["HighPack"]); + assert!(options.preview_render); + } + + #[test] + fn rejects_invalid_bbox_condition() { + let error = normalize_bbox_condition(Some(vec![1.0, 0.0, 3.0])) + .expect_err("invalid bbox should fail"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn decodes_png_data_url() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let image = decode_image_data_url(&data_url, 1).expect("image should decode"); + + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.file_name, "reference-01.png"); + assert!(!image.bytes.is_empty()); + } + + #[test] + fn extracts_submit_response_from_nested_payload() { + let response = build_submit_response( + contract::Hyper3dGenerationMode::TextToModel, + json!({ + "uuid": "task-1", + "subscription_key": "sub-1", + "jobs": [{ "uuid": "job-1" }], + "message": "submitted" + }), + ) + .expect("submit response should build"); + + assert_eq!(response.task_uuid, "task-1"); + assert_eq!(response.subscription_key, "sub-1"); + assert_eq!(response.job_uuids, vec!["job-1"]); + } + + #[test] + fn extracts_download_files_from_list() { + let files = extract_download_files(&json!({ + "list": [ + { "name": "model.glb", "url": "https://cdn.example/model.glb" }, + { "name": "preview.png", "url": "https://cdn.example/preview.png" } + ] + })); + + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "model.glb"); + } + + #[test] + fn normalizes_status_values() { + assert_eq!(normalize_task_status("Waiting"), "waiting"); + assert_eq!(normalize_task_status("Generating"), "generating"); + assert_eq!(normalize_task_status("Done"), "done"); + assert_eq!(normalize_task_status("Failed"), "failed"); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index c27f4e19..9a0bc43e 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,6 +36,7 @@ mod custom_world_rpg_draft_prompts; mod error_middleware; mod health; mod http_error; +mod hyper3d_generation; mod llm; mod llm_model_routing; mod login_options; diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index fd55bdf9..040f8993 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,14 +1,15 @@ -use std::time::{Duration, Instant}; +use std::time::Duration; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::header; use serde_json::{Map, Value, json}; -use tokio::time::sleep; use crate::{http_error::AppError, state::AppState}; pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; +pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all"; +const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; #[derive(Clone, Debug)] pub(crate) struct OpenAiImageSettings { @@ -31,37 +32,41 @@ pub(crate) struct DownloadedOpenAiImage { pub extension: String, } -// 中文注释:RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。 +// 中文注释:RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { - let base_url = state.config.apimart_base_url.trim().trim_end_matches('/'); + let base_url = state + .config + .vector_engine_base_url + .trim() + .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "apimart", - "reason": "APIMART_BASE_URL 未配置", + "provider": VECTOR_ENGINE_PROVIDER, + "reason": "VECTOR_ENGINE_BASE_URL 未配置", })), ); } let api_key = state .config - .apimart_api_key + .vector_engine_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "apimart", - "reason": "APIMART_API_KEY 未配置", + "provider": VECTOR_ENGINE_PROVIDER, + "reason": "VECTOR_ENGINE_API_KEY 未配置", })) })?; Ok(OpenAiImageSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), - request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1), + request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), }) } @@ -73,8 +78,8 @@ pub(crate) fn build_openai_image_http_client( .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "apimart", - "message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"), + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), })) }) } @@ -97,11 +102,12 @@ pub(crate) async fn create_openai_image_generation( reference_images, ); let response = http_client - .post(format!("{}/images/generations", settings.base_url)) + .post(vector_engine_images_generation_url(settings)) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) + .header(header::ACCEPT, "application/json") .header(header::CONTENT_TYPE, "application/json") .json(&request_body) .send() @@ -124,40 +130,29 @@ pub(crate) async fn create_openai_image_generation( } let response_json = parse_json_payload(response_text.as_str(), failure_context)?; + let generation_id = extract_generation_id(&response_json.payload) + .unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros())); + let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") + .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { - return download_images_from_urls( - http_client, - format!("apimart-{}", current_utc_micros()), - image_urls, - candidate_count, - ) - .await; + let mut generated = + download_images_from_urls(http_client, generation_id, image_urls, candidate_count) + .await?; + generated.actual_prompt = actual_prompt; + return Ok(generated); } let b64_images = extract_b64_images(&response_json.payload); if !b64_images.is_empty() { - return Ok(images_from_base64( - format!("apimart-{}", current_utc_micros()), - b64_images, - candidate_count, - )); + let mut generated = images_from_base64(generation_id, b64_images, candidate_count); + generated.actual_prompt = actual_prompt; + return Ok(generated); } - let task_id = extract_task_id(&response_json.payload).ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", - "message": format!("{failure_context}:上游未返回 task_id 或图片"), - })) - })?; - - wait_openai_generated_images( - http_client, - settings, - task_id.as_str(), - candidate_count, - failure_context, - ) - .await + Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{failure_context}:VectorEngine 未返回图片地址"), + }))) } pub(crate) fn build_openai_image_request_body( @@ -170,14 +165,13 @@ pub(crate) fn build_openai_image_request_body( let mut body = Map::from_iter([ ( "model".to_string(), - Value::String(GPT_IMAGE_2_MODEL.to_string()), + Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()), ), ( "prompt".to_string(), Value::String(build_prompt_with_negative(prompt, negative_prompt)), ), ("n".to_string(), json!(candidate_count.clamp(1, 4))), - ("official_fallback".to_string(), Value::Bool(true)), ( "size".to_string(), Value::String(normalize_image_size(size)), @@ -185,7 +179,7 @@ pub(crate) fn build_openai_image_request_body( ]); if !reference_images.is_empty() { - body.insert("image_urls".to_string(), json!(reference_images)); + body.insert("image".to_string(), json!(reference_images)); } Value::Object(body) @@ -205,109 +199,16 @@ fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> St fn normalize_image_size(size: &str) -> String { match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1:1", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9", + "1024*1024" | "1024x1024" | "1:1" => "1024x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" + | "1536x1024" | "2048x1152" | "2k" => "1536x1024", + "1024*1536" | "1024x1536" | "9:16" => "1024x1536", value if !value.is_empty() => value, - _ => "1:1", + _ => "1024x1024", } .to_string() } -async fn wait_openai_generated_images( - http_client: &reqwest::Client, - settings: &OpenAiImageSettings, - task_id: &str, - candidate_count: u32, - failure_context: &str, -) -> Result { - let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); - sleep(Duration::from_secs(10)).await; - - while Instant::now() < deadline { - let poll_response = http_client - .get(format!("{}/tasks/{}", settings.base_url, task_id)) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .send() - .await - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:查询图片生成任务失败:{error}" - )) - })?; - let poll_status = poll_response.status(); - let poll_text = poll_response.text().await.map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:读取图片生成任务响应失败:{error}" - )) - })?; - if !poll_status.is_success() { - return Err(map_openai_image_upstream_error( - poll_status.as_u16(), - poll_text.as_str(), - failure_context, - )); - } - - let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?; - let task_status = find_first_string_by_key(&poll_json.payload, "status") - .or_else(|| find_first_string_by_key(&poll_json.payload, "task_status")) - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - if matches!(task_status.as_str(), "completed" | "succeeded" | "success") { - let image_urls = extract_image_urls(&poll_json.payload); - if image_urls.is_empty() { - let b64_images = extract_b64_images(&poll_json.payload); - if b64_images.is_empty() { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": "apimart", - "message": format!("{failure_context}:任务成功但未返回图片"), - }), - )); - } - - let mut generated = - images_from_base64(task_id.to_string(), b64_images, candidate_count); - generated.actual_prompt = - find_first_string_by_key(&poll_json.payload, "actual_prompt"); - return Ok(generated); - } - - let mut generated = download_images_from_urls( - http_client, - task_id.to_string(), - image_urls, - candidate_count, - ) - .await?; - generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt"); - return Ok(generated); - } - if matches!( - task_status.as_str(), - "failed" | "error" | "canceled" | "cancelled" | "unknown" - ) { - return Err(map_openai_image_upstream_error( - poll_status.as_u16(), - poll_text.as_str(), - failure_context, - )); - } - sleep(Duration::from_secs(3)).await; - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", - "message": format!("{failure_context}:图片生成超时或未返回图片地址"), - })), - ) -} - async fn download_images_from_urls( http_client: &reqwest::Client, task_id: String, @@ -377,7 +278,7 @@ pub(crate) async fn download_remote_image( if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": "下载生成图片失败", "status": status.as_u16(), })), @@ -400,7 +301,7 @@ fn parse_json_payload( .map(|payload| ParsedJsonPayload { payload }) .map_err(|error| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": format!("{failure_context}:解析响应失败:{error}"), "rawExcerpt": truncate_raw(raw_text), })) @@ -409,7 +310,7 @@ fn parse_json_payload( fn map_openai_image_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": message, })) } @@ -421,14 +322,14 @@ fn map_openai_image_upstream_error( ) -> AppError { let message = parse_api_error_message(raw_text, failure_context); tracing::warn!( - provider = "apimart", + provider = VECTOR_ENGINE_PROVIDER, upstream_status, raw_excerpt = %truncate_raw(raw_text), message, - "APIMart 图片生成上游错误" + "VectorEngine 图片生成上游错误" ); AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "apimart", + "provider": VECTOR_ENGINE_PROVIDER, "message": message, "upstreamStatus": upstream_status, "rawExcerpt": truncate_raw(raw_text), @@ -516,10 +417,10 @@ fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { results.into_iter().next() } -fn extract_task_id(payload: &Value) -> Option { - find_first_string_by_key(payload, "task_id") - .or_else(|| find_first_string_by_key(payload, "taskId")) - .or_else(|| find_first_string_by_key(payload, "id")) +fn extract_generation_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "id") + .or_else(|| find_first_string_by_key(payload, "created")) + .or_else(|| find_first_string_by_key(payload, "request_id")) } fn extract_image_urls(payload: &Value) -> Vec { @@ -542,6 +443,14 @@ fn extract_b64_images(payload: &Value) -> Vec { values } +fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) + } +} + fn normalize_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') @@ -602,7 +511,7 @@ mod tests { use super::*; #[test] - fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() { + fn gpt_image_2_request_uses_vector_engine_contract() { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), @@ -611,11 +520,11 @@ mod tests { &["data:image/png;base64,abcd".to_string()], ); - assert_eq!(body["model"], GPT_IMAGE_2_MODEL); - assert_eq!(body["size"], "16:9"); + assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); - assert_eq!(body["official_fallback"], true); - assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd"); + assert!(body.get("official_fallback").is_none()); + assert_eq!(body["image"][0], "data:image/png;base64,abcd"); assert!(body["prompt"].as_str().unwrap_or_default().contains("避免")); } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 10a8fb72..17e39ef2 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,6 +1,6 @@ use std::{ collections::BTreeMap, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -68,7 +68,6 @@ use spacetime_client::{ PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; -use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, @@ -80,6 +79,7 @@ use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, + openai_image_generation::VECTOR_ENGINE_GPT_IMAGE_2_MODEL, platform_errors::map_oss_error, prompt::puzzle::{ draft::{ @@ -112,8 +112,8 @@ const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; #[cfg(test)] const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; -const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1"; -const PUZZLE_APIMART_GEMINI_RESOLUTION: &str = "1K"; +const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; +const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768; pub async fn create_puzzle_agent_session( @@ -941,7 +941,7 @@ pub async fn execute_puzzle_agent_action( Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - // 中文注释:APIMart/OSS 已生成真实图片时,Maincloud 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 + // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session.session_id, @@ -3172,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover( .map(|session| (session, false)) .or_else(|error| { if is_spacetimedb_connectivity_app_error(&error) { - // 中文注释:首图已落 OSS 时,Maincloud 短暂不可用先返回本地快照,避免整次 APIMart 生图被判失败。 + // 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %compiled_session.session_id, @@ -3271,7 +3271,7 @@ async fn compile_puzzle_draft_with_uploaded_cover( &target_level.picture_description, &draft.summary, ); - // 中文注释:关闭 AI 重绘时不请求 APIMart,也不进入光点扣费流程;上传图直接成为首关正式图候选。 + // 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", compiled_session.session_id, @@ -3874,18 +3874,23 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); - let provider = if message.contains("APIMart") + let provider = if message.contains("VectorEngine") + || message.contains("vector-engine") + || message.contains("VECTOR_ENGINE") + || message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART") { - "apimart" + VECTOR_ENGINE_PROVIDER } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { "puzzle-assets" } else { "spacetimedb" }; - let status = if provider == "apimart" - && (message.contains("APIMART_API_KEY") + let status = if provider == VECTOR_ENGINE_PROVIDER + && (message.contains("VECTOR_ENGINE_API_KEY") + || message.contains("VECTOR_ENGINE_BASE_URL") + || message.contains("APIMART_API_KEY") || message.contains("APIMART_BASE_URL") || message.contains("未配置")) { @@ -3899,6 +3904,9 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { } else if matches!(error, SpacetimeClientError::Runtime(_)) && (message.contains("生成") || message.contains("上游") + || message.contains("VectorEngine") + || message.contains("vector-engine") + || message.contains("VECTOR_ENGINE") || message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART") diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 47be20e9..35b785ed 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -784,7 +784,7 @@ fn build_creative_agent_gpt5_client( config.apimart_base_url.clone(), api_key.to_string(), platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(), - config.apimart_image_request_timeout_ms, + config.llm_request_timeout_ms, 0, config.llm_retry_backoff_ms, )? diff --git a/server-rs/crates/shared-contracts/src/hyper3d.rs b/server-rs/crates/shared-contracts/src/hyper3d.rs new file mode 100644 index 00000000..cd663eca --- /dev/null +++ b/server-rs/crates/shared-contracts/src/hyper3d.rs @@ -0,0 +1,169 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Hyper3dGenerationMode { + TextToModel, + ImageToModel, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dTextToModelRequest { + pub prompt: String, + #[serde(default)] + pub negative_prompt: Option, + #[serde(default)] + pub seed: Option, + #[serde(default)] + pub geometry_file_format: Option, + #[serde(default)] + pub material: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub mesh_mode: Option, + #[serde(default)] + pub addons: Vec, + #[serde(default)] + pub bbox_condition: Option>, + #[serde(default)] + pub preview_render: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dImageToModelRequest { + #[serde(default)] + pub image_data_urls: Vec, + #[serde(default)] + pub image_urls: Vec, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub condition_mode: Option, + #[serde(default)] + pub seed: Option, + #[serde(default)] + pub geometry_file_format: Option, + #[serde(default)] + pub material: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub mesh_mode: Option, + #[serde(default)] + pub addons: Vec, + #[serde(default)] + pub bbox_condition: Option>, + #[serde(default)] + pub preview_render: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dTaskSubmitResponse { + pub ok: bool, + pub provider: String, + pub mode: Hyper3dGenerationMode, + pub task_uuid: String, + pub subscription_key: String, + pub job_uuids: Vec, + pub message: Option, + pub tier: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dTaskStatusRequest { + pub subscription_key: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dTaskStatusResponse { + pub ok: bool, + pub provider: String, + pub status: String, + #[serde(default)] + pub jobs: Vec, + pub raw: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dJobStatusPayload { + pub uuid: Option, + pub status: String, + pub progress: Option, + pub message: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dDownloadRequest { + pub task_uuid: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dDownloadResponse { + pub ok: bool, + pub provider: String, + #[serde(default)] + pub files: Vec, + pub raw: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Hyper3dDownloadFilePayload { + pub name: String, + pub url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn text_to_model_request_uses_camel_case_fields() { + let payload = serde_json::to_value(Hyper3dTextToModelRequest { + prompt: "低多边形宝箱".to_string(), + negative_prompt: Some("文字".to_string()), + seed: Some(42), + geometry_file_format: Some("glb".to_string()), + material: Some("PBR".to_string()), + quality: Some("medium".to_string()), + mesh_mode: Some("Quad".to_string()), + addons: vec!["HighPack".to_string()], + bbox_condition: Some(vec![1.0, 1.0, 1.0]), + preview_render: Some(true), + }) + .expect("request should serialize"); + + assert_eq!(payload["geometryFileFormat"], json!("glb")); + assert_eq!(payload["meshMode"], json!("Quad")); + assert_eq!(payload["bboxCondition"], json!([1.0, 1.0, 1.0])); + } + + #[test] + fn submit_response_keeps_mode_as_kebab_case() { + let payload = serde_json::to_value(Hyper3dTaskSubmitResponse { + ok: true, + provider: "hyper3d-rodin".to_string(), + mode: Hyper3dGenerationMode::ImageToModel, + task_uuid: "task-1".to_string(), + subscription_key: "sub-1".to_string(), + job_uuids: vec!["job-1".to_string()], + message: Some("submitted".to_string()), + tier: "Gen-2".to_string(), + }) + .expect("response should serialize"); + + assert_eq!(payload["mode"], json!("image-to-model")); + assert_eq!(payload["subscriptionKey"], json!("sub-1")); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 26f69f2c..49a4da77 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -7,6 +7,7 @@ pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; pub mod creative_agent; +pub mod hyper3d; pub mod llm; pub mod match3d_agent; pub mod match3d_runtime; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0bb91bb2..1ad96eb7 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -319,6 +319,7 @@ type PuzzleRuntimeReturnStage = | 'puzzle-gallery-detail' | 'work-detail' | 'platform'; +type PuzzleRuntimeAuthMode = 'default' | 'isolated'; type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated'; @@ -387,6 +388,8 @@ const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = { notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; +const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS = + RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS; function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { const rawTime = entry.publishedAt ?? entry.updatedAt; @@ -1568,6 +1571,8 @@ export function PlatformEntryFlowShellImpl({ useState(null); const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] = useState('puzzle-gallery-detail'); + const [puzzleRuntimeAuthMode, setPuzzleRuntimeAuthMode] = + useState('default'); const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false); const submittedPuzzleLeaderboardKeysRef = useRef(new Set()); const [puzzleRun, setPuzzleRun] = useState(null); @@ -2230,6 +2235,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleWorks((current) => [response.item, ...current]); setSelectedPuzzleDetail(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setPuzzleOnboardingDraft(null); setPuzzleOnboardingPrompt(''); setPuzzleOnboardingPhase('input'); @@ -2268,10 +2274,11 @@ export function PlatformEntryFlowShellImpl({ setPuzzleOnboardingPhase('input'); setPuzzleOnboardingError(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setSelectedPuzzleDetail(null); - platformBootstrap.setPlatformTab('home'); + platformBootstrap.setPlatformTab(authUi?.user ? 'home' : 'category'); setSelectionStage('platform'); - }, [platformBootstrap, setSelectionStage]); + }, [authUi?.user, platformBootstrap, setSelectionStage]); useEffect(() => { if ( @@ -2321,6 +2328,7 @@ export function PlatformEntryFlowShellImpl({ markPuzzleOnboardingSeen(); window.setTimeout(() => { setPuzzleRun(startLocalPuzzleRun(item)); + setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('platform'); setSelectionStage('puzzle-runtime'); }, PUZZLE_ONBOARDING_GENERATED_DELAY_MS); @@ -2953,6 +2961,7 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleAgentWorkspace = useCallback(async () => { setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setPuzzleOperation(null); setPuzzleGenerationState(null); setPuzzleFormDraftPayload(null); @@ -2997,6 +3006,7 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setPuzzleOperation(null); setPuzzleGenerationState(null); setPuzzleFormDraftPayload(null); @@ -3137,6 +3147,7 @@ export function PlatformEntryFlowShellImpl({ setSelectedPuzzleDetail(null); setPuzzleRuntimeReturnStage('puzzle-gallery-detail'); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setPuzzleGenerationState(null); setIsPuzzleNextLevelGenerating(false); setPuzzleShelfError(null); @@ -3283,6 +3294,7 @@ export function PlatformEntryFlowShellImpl({ const leavePuzzleFlow = useCallback(() => { setPuzzleOperation(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setPuzzleGenerationState(null); setIsPuzzleNextLevelGenerating(false); setActiveCreativeAgentSessionId(null); @@ -3773,6 +3785,7 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow.setSession(response.session); setPuzzleOperation(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); enterCreateTab(); setActiveCreativeAgentSessionId(creativeAgentSession.sessionId); setCreativeDraftEditError(null); @@ -4007,7 +4020,7 @@ export function PlatformEntryFlowShellImpl({ detailItem?: PuzzleWorkSummary, mirrorErrorToPublicDetail = false, levelId?: string | null, - options: { embedded?: boolean } = {}, + options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {}, ) => { if (isPuzzleBusy) { return false; @@ -4023,14 +4036,19 @@ export function PlatformEntryFlowShellImpl({ profileId: item.profileId, levelId: levelId ?? null, }; - const { run } = options.embedded - ? await startPuzzleRun( - startRunPayload, - RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, - ) - : await startPuzzleRun(startRunPayload); + const authMode = options.embedded + ? 'isolated' + : (options.authMode ?? 'default'); + const { run } = + authMode === 'isolated' + ? await startPuzzleRun( + startRunPayload, + PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ) + : await startPuzzleRun(startRunPayload); setSelectedPuzzleDetail(item); setPuzzleRun(run); + setPuzzleRuntimeAuthMode(authMode); setPuzzleRuntimeReturnStage(returnStage); if (!options.embedded) { setSelectionStage('puzzle-runtime'); @@ -4255,6 +4273,7 @@ export function PlatformEntryFlowShellImpl({ const run = startLocalPuzzleRun(item); setSelectedPuzzleDetail(item); setPuzzleRun(run); + setPuzzleRuntimeAuthMode('default'); setPuzzleRuntimeReturnStage('puzzle-result'); setSelectionStage('puzzle-runtime'); } catch (error) { @@ -4496,10 +4515,17 @@ export function PlatformEntryFlowShellImpl({ : await getPuzzleGalleryDetail(currentLevel.profileId).then( (response) => response.item, ); - const { run } = await startPuzzleRun({ + const startRunPayload = { profileId: currentLevel.profileId, levelId: resolvePuzzleRestartLevelId(currentRun, detailItem), - }); + }; + const { run } = + puzzleRuntimeAuthMode === 'isolated' + ? await startPuzzleRun( + startRunPayload, + PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ) + : await startPuzzleRun(startRunPayload); setSelectedPuzzleDetail(detailItem); puzzleRunRef.current = run; setPuzzleRun(run); @@ -4513,6 +4539,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, puzzleRun, + puzzleRuntimeAuthMode, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -4618,7 +4645,16 @@ export function PlatformEntryFlowShellImpl({ return; } - void submitPuzzleLeaderboard(puzzleRun.runId, payload) + const submitLeaderboardPromise = + puzzleRuntimeAuthMode === 'isolated' + ? submitPuzzleLeaderboard( + puzzleRun.runId, + payload, + PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ) + : submitPuzzleLeaderboard(puzzleRun.runId, payload); + + void submitLeaderboardPromise .then(({ run }) => { setPuzzleRun((currentRun) => { if (!currentRun) { @@ -4641,6 +4677,7 @@ export function PlatformEntryFlowShellImpl({ authUi?.user?.displayName, platformBootstrap, puzzleRun, + puzzleRuntimeAuthMode, resolvePuzzleErrorMessage, setPuzzleError, ]); @@ -4678,10 +4715,20 @@ export function PlatformEntryFlowShellImpl({ : getPuzzleGalleryDetail(targetProfileId).then( (response) => response.item, ); + const advancePromise = + puzzleRuntimeAuthMode === 'isolated' + ? advancePuzzleNextLevel( + puzzleRun.runId, + { + targetProfileId, + }, + PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ) + : advancePuzzleNextLevel(puzzleRun.runId, { + targetProfileId, + }); const [{ run }, item] = await Promise.all([ - advancePuzzleNextLevel(puzzleRun.runId, { - targetProfileId, - }), + advancePromise, itemPromise, ]); setSelectedPuzzleDetail(item); @@ -4695,7 +4742,14 @@ export function PlatformEntryFlowShellImpl({ return; } - const { run } = await advancePuzzleNextLevel(puzzleRun.runId); + const { run } = + puzzleRuntimeAuthMode === 'isolated' + ? await advancePuzzleNextLevel( + puzzleRun.runId, + {}, + PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS, + ) + : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); @@ -4708,6 +4762,7 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy, isPuzzleLeaderboardBusy, puzzleRun, + puzzleRuntimeAuthMode, resolvePuzzleErrorMessage, selectedPuzzleDetail, setIsPuzzleBusy, @@ -4732,6 +4787,7 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow.setSession(response.session); setPuzzleOperation(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); enterCreateTab(); setSelectionStage('puzzle-result'); }) @@ -4777,6 +4833,7 @@ export function PlatformEntryFlowShellImpl({ : currentRun; puzzleRunRef.current = null; setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setActiveRecommendRuntimeKind(null); if (closedRun.currentLevel) { @@ -5596,34 +5653,36 @@ export function PlatformEntryFlowShellImpl({ const openRecommendGalleryDetail = useCallback( (entry: PlatformPublicGalleryCard) => { - if (isBigFishGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + runProtectedAction(() => { + if (isBigFishGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isPuzzleGalleryEntry(entry)) { - void openPuzzlePublicWorkDetail(entry.profileId, { - tab: platformBootstrap.platformTab, - }); - return; - } + if (isPuzzleGalleryEntry(entry)) { + void openPuzzlePublicWorkDetail(entry.profileId, { + tab: platformBootstrap.platformTab, + }); + return; + } - if (isMatch3DGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + if (isMatch3DGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isSquareHoleGalleryEntry(entry)) { - openPublicWorkDetail(entry); - return; - } + if (isSquareHoleGalleryEntry(entry)) { + openPublicWorkDetail(entry); + return; + } - if (isVisualNovelGalleryEntry(entry)) { - void openVisualNovelPublicWorkDetail(entry.profileId); - return; - } + if (isVisualNovelGalleryEntry(entry)) { + void openVisualNovelPublicWorkDetail(entry.profileId); + return; + } - void openRpgPublicWorkDetail(entry); + void openRpgPublicWorkDetail(entry); + }); }, [ openPuzzlePublicWorkDetail, @@ -5631,6 +5690,7 @@ export function PlatformEntryFlowShellImpl({ openRpgPublicWorkDetail, openVisualNovelPublicWorkDetail, platformBootstrap.platformTab, + runProtectedAction, ], ); const openPuzzleDetail = useCallback( @@ -5673,6 +5733,7 @@ export function PlatformEntryFlowShellImpl({ async (item: PuzzleWorkSummary) => { setPuzzleOperation(null); setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); setSelectedPuzzleDetail(null); if (!item.sourceSessionId?.trim()) { if (item.publicationStatus === 'published') { @@ -5960,6 +6021,8 @@ export function PlatformEntryFlowShellImpl({ 'work-detail', work, true, + null, + { authMode: 'isolated' }, ); return; } @@ -6462,6 +6525,7 @@ export function PlatformEntryFlowShellImpl({ if ( selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || + !platformBootstrap.isAuthenticated || !platformBootstrap.canReadProtectedData || platformBootstrap.isLoadingPlatform ) { @@ -6494,6 +6558,7 @@ export function PlatformEntryFlowShellImpl({ isStartingRecommendEntry, platformBootstrap.canReadProtectedData, platformBootstrap.isLoadingPlatform, + platformBootstrap.isAuthenticated, platformBootstrap.platformTab, recommendRuntimeEntries, selectRecommendRuntimeEntry, @@ -8594,6 +8659,9 @@ export function PlatformEntryFlowShellImpl({ selectedPuzzleDetail.profileId, 'puzzle-gallery-detail', selectedPuzzleDetail, + false, + null, + { authMode: 'isolated' }, ); }} /> diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 063bd17a..afd0f13e 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -442,6 +442,40 @@ describe('PuzzleResultView', () => { ]); }); + test('keeps publish dialog open and shows backend publish error', () => { + const onExecuteAction = vi.fn(); + + const { rerender } = render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /发布/u })); + const dialog = screen.getByRole('dialog', { name: '发布拼图作品' }); + fireEvent.click( + within(dialog).getByRole('button', { name: '发布到广场' }), + ); + + rerender( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + const publishDialog = screen.getByRole('dialog', { + name: '发布拼图作品', + }); + expect(publishDialog).toBeTruthy(); + expect(within(publishDialog).getByText('光点余额不足')).toBeTruthy(); + }); + test('generates six tags after work title and description are filled', () => { const onExecuteAction = vi.fn(); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 2ed3bdc1..8e2ecf58 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -1014,6 +1014,7 @@ function PuzzleLevelDetailDialog({ } function PuzzlePublishDialog({ + actionError, blockers, editState, imageRefreshKey, @@ -1022,6 +1023,7 @@ function PuzzlePublishDialog({ onClose, onPublish, }: { + actionError: string | null; blockers: string[]; editState: DraftEditState; imageRefreshKey: string; @@ -1076,7 +1078,11 @@ function PuzzlePublishDialog({
发布检查
- {publishReady ? ( + {actionError ? ( +
+ {actionError} +
+ ) : publishReady ? (
当前作品已满足发布条件。
@@ -1361,6 +1367,7 @@ function PuzzleWorkInfoTab({ } function PuzzleResultActionBar({ + actionError, editState, imageRefreshKey, isBusy, @@ -1368,6 +1375,7 @@ function PuzzleResultActionBar({ publishBlockers, onPublish, }: { + actionError: string | null; editState: DraftEditState; imageRefreshKey: string; isBusy: boolean; @@ -1376,12 +1384,21 @@ function PuzzleResultActionBar({ onPublish: () => void; }) { const [showPublishDialog, setShowPublishDialog] = useState(false); + const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false); + + const closePublishDialog = () => { + setHasAttemptedPublish(false); + setShowPublishDialog(false); + }; return (
@@ -1671,6 +1692,7 @@ export function PuzzleResultView({ ) : null} { expect(screen.getByTestId('puzzle-board')).toBeTruthy(); }); - expect(startPuzzleRun).toHaveBeenCalledWith({ - profileId: 'puzzle-profile-public-1', - levelId: null, - }); + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: 'puzzle-profile-public-1', + levelId: null, + }, + ISOLATED_RUNTIME_AUTH_OPTIONS, + ); await user.click(document.querySelector('[data-piece-id="piece-0"]')!); await user.click(document.querySelector('[data-piece-id="piece-1"]')!); @@ -3695,6 +3700,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa elapsedMs: 18_000, nickname: '测试玩家', }, + ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); @@ -3711,6 +3717,8 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa await waitFor(() => { expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedFirstLevel.runId, + {}, + ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect( @@ -3875,6 +3883,7 @@ test('formal puzzle similar work keeps current run level progression', async () expect(advancePuzzleNextLevel).toHaveBeenCalledWith( clearedThirdLevel.runId, { targetProfileId: 'puzzle-profile-similar-2' }, + ISOLATED_RUNTIME_AUTH_OPTIONS, ); }); expect(startPuzzleRun).not.toHaveBeenCalled(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index ce891a8a..aef47b45 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -310,16 +310,6 @@ const originalMatchMedia = window.matchMedia; const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; -function dispatchClientYPointerEvent( - target: HTMLElement, - type: string, - clientY: number, -) { - const event = new Event(type, { bubbles: true, cancelable: true }); - Object.assign(event, { clientY }); - target.dispatchEvent(event); -} - const puzzlePublicEntry = { sourceType: 'puzzle', workId: 'puzzle-work-public-1', @@ -534,6 +524,7 @@ function renderLoggedOutHomeView( | 'onSelectPreviousRecommendEntry' > > = {}, + activeTab: RpgEntryHomeViewProps['activeTab'] = 'home', ) { return render( > = {}, ) { + const authSpies = { + openLoginModal: vi.fn(), + }; + function StatefulLoggedOutHomeView() { const [activeTab, setActiveTab] = - useState('home'); + useState('category'); return ( } + recommendRuntimeContent={ + overrides.recommendRuntimeContent ?? ( +
+ ) + } + activeRecommendEntryKey={overrides.activeRecommendEntryKey} + onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry} + onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry} onOpenLibraryDetail={vi.fn()} onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()} /> @@ -659,7 +665,10 @@ function renderStatefulLoggedOutHomeView( ); } - return render(); + return { + ...render(), + openLoginModal: authSpies.openLoginModal, + }; } afterEach(() => { @@ -1111,35 +1120,80 @@ test('public gallery cards hide work code until detail is opened', async () => { expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry); }); -test('mobile recommend page renders runtime viewport without bottom work cards', () => { +test('logged out mobile shell defaults to discover tab', () => { + const { container } = renderStatefulLoggedOutHomeView({ + latestEntries: [puzzlePublicEntry], + }); + + const activePanel = container.querySelector('.platform-tab-panel--active'); + expect(activePanel?.id).toBe('platform-tab-panel-category'); + expect(screen.getByPlaceholderText('搜索作品号、名称、作者、描述')).toBeTruthy(); +}); + +test('logged out recommend tab opens login modal and shows cover only', async () => { + const user = userEvent.setup(); + const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + }); + const bottomNav = container.querySelector('.platform-bottom-nav'); + if (!bottomNav) { + throw new Error('缺少底部导航'); + } + + await user.click( + within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), + ); + + expect(openLoginModal).toHaveBeenCalledTimes(1); + expect(container.querySelector('.platform-recommend-cover-only')).toBeTruthy(); + expect(screen.queryByTestId('recommend-runtime')).toBeNull(); + expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull(); + expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); +}); + +test('logged out recommend cover opens login modal again', async () => { + const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); - renderLoggedOutHomeView(vi.fn(), { + const { openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', onOpenGalleryDetail, }); + const bottomNav = document.querySelector('.platform-bottom-nav'); + if (!bottomNav) { + throw new Error('缺少底部导航'); + } - expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); - const runtimePanel = document.querySelector('.platform-recommend-runtime-panel'); - expect(runtimePanel).toBeTruthy(); - expect(runtimePanel?.className).not.toContain('bg-black'); - expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull(); + await user.click( + within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), + ); + await user.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u })); + + expect(openLoginModal).toHaveBeenCalledTimes(2); + expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function)); + expect(onOpenGalleryDetail).not.toHaveBeenCalled(); +}); + +test('logged out mobile recommend page renders cover instead of runtime', () => { + const onOpenGalleryDetail = vi.fn(); + renderLoggedOutHomeView( + vi.fn(), + { + latestEntries: [puzzlePublicEntry], + activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', + onOpenGalleryDetail, + }, + 'home', + ); + + expect(screen.queryByTestId('recommend-runtime')).toBeNull(); + expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); expect( document.querySelector('.platform-public-work-card__cover'), ).toBeNull(); - expect(screen.getByText('拼图玩家')).toBeTruthy(); expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); - expect(screen.getAllByText('20').length).toBeGreaterThan(0); - expect(screen.getAllByText('12').length).toBeGreaterThan(0); - expect(screen.queryByRole('button', { name: '切换到 奇幻拼图' })).toBeNull(); - expect( - screen.queryByRole('button', { name: '查看 奇幻拼图 详情' }), - ).toBeNull(); - expect( - screen.queryByRole('button', { name: '打开 奇幻拼图 详情' }), - ).toBeNull(); - expect(document.querySelector('.platform-recommend-switcher')).toBeNull(); - fireEvent.click(screen.getByLabelText('奇幻拼图 作品信息')); + fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u })); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); @@ -1151,38 +1205,15 @@ test('mobile recommend loading state is themed instead of hardcoded black', () = recommendRuntimeContent: null, }); - const loadingState = screen.getByText('加载中...'); - expect(loadingState.className).toContain('platform-recommend-runtime-state'); - expect(loadingState.className).not.toContain('bg-black'); + expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy(); }); -test('mobile recommend meta swipes between public works', () => { - const onSelectNextRecommendEntry = vi.fn(); - const onSelectPreviousRecommendEntry = vi.fn(); - - renderLoggedOutHomeView(vi.fn(), { - latestEntries: [puzzlePublicEntry], - activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', - onSelectNextRecommendEntry, - onSelectPreviousRecommendEntry, - }); - - const meta = screen.getByLabelText('奇幻拼图 作品信息'); - dispatchClientYPointerEvent(meta, 'pointerdown', 240); - dispatchClientYPointerEvent(meta, 'pointerup', 180); - expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); - expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled(); - - dispatchClientYPointerEvent(meta, 'pointerdown', 180); - dispatchClientYPointerEvent(meta, 'pointerup', 240); - expect(onSelectPreviousRecommendEntry).toHaveBeenCalledTimes(1); -}); - -test('active recommend bottom tab selects next work instead of navigating', async () => { +test('logged out active recommend bottom tab selects next work without login', async () => { const user = userEvent.setup(); const onSelectNextRecommendEntry = vi.fn(); + const openLoginModal = vi.fn(); - renderLoggedOutHomeView(vi.fn(), { + renderLoggedOutHomeView(openLoginModal, { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', onSelectNextRecommendEntry, @@ -1191,6 +1222,7 @@ test('active recommend bottom tab selects next work instead of navigating', asyn await user.click(screen.getByRole('button', { name: '下一个' })); expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1); + expect(openLoginModal).not.toHaveBeenCalled(); }); test('mobile recommend meta loads real author avatar from public user summary', async () => { @@ -1210,7 +1242,7 @@ test('mobile recommend meta loads real author avatar from public user summary', await waitFor(() => { expect( document - .querySelector('.platform-recommend-work-meta__avatar img') + .querySelector('.platform-recommend-cover-only__author img') ?.getAttribute('src'), ).toBe('data:image/png;base64,AUTHOR'); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 1195e4aa..2ad471b1 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -561,6 +561,66 @@ function WorldCard({ ); } +function RecommendCoverOnlyCard({ + entry, + authorAvatarUrl, + onClick, +}: { + entry: PlatformPublicGalleryCard; + authorAvatarUrl?: string | null; + onClick: () => void; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const displayName = formatPlatformWorkDisplayName(entry.worldName); + const typeLabel = describePublicGalleryCardKind(entry); + const authorName = entry.authorDisplayName.trim() || '玩家'; + const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); + const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; + + return ( + + ); +} + function CreationLibraryCard({ entry, onClick, @@ -3049,9 +3109,9 @@ export function RpgEntryHomeView({ useEffect(() => { if (!visibleTabs.includes(activeTab)) { - onTabChange('home'); + onTabChange(isAuthenticated ? 'home' : 'category'); } - }, [activeTab, onTabChange, visibleTabs]); + }, [activeTab, isAuthenticated, onTabChange, visibleTabs]); useEffect(() => { setVisitedTabs((currentTabs) => { @@ -3705,6 +3765,18 @@ export function RpgEntryHomeView({ ) ?? recommendedFeedEntries[0] ?? null; + const openActiveRecommendEntry = useCallback(() => { + if (!activeRecommendEntry) { + return; + } + + if (!isAuthenticated) { + authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry)); + return; + } + + onOpenGalleryDetail(activeRecommendEntry); + }, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]); const selectNextRecommendEntry = useCallback(() => { onSelectNextRecommendEntry?.(); }, [onSelectNextRecommendEntry]); @@ -3787,6 +3859,12 @@ export function RpgEntryHomeView({
正在读取公开作品...
+ ) : !isAuthenticated && activeRecommendEntry ? ( + ) : recommendRuntimeError ? (