1
This commit is contained in:
@@ -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`
|
## 2026-05-08 APIMart 接口统一携带 `official_fallback`
|
||||||
|
|
||||||
- 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。
|
- 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server`、`platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。
|
||||||
|
|||||||
@@ -43,6 +43,14 @@
|
|||||||
- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。
|
- 验证:提交前检查 `git diff -- .hermes`,确认没有密钥、会话记录或个人路径敏感信息。
|
||||||
- 关联:`.hermes/README.md`。
|
- 关联:`.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 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
|
- 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。
|
||||||
@@ -67,6 +75,14 @@
|
|||||||
- 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
|
- 验证:本地 SpacetimeDB 可正常启动并 publish / 访问。
|
||||||
- 关联:`docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md`。
|
- 关联:`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 请求
|
## Vite SPA fallback 吞掉 API 请求
|
||||||
|
|
||||||
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。
|
- 现象:本地请求 `/api/profile/*` 等接口时返回 HTML,被前端当 JSON 解析报错。
|
||||||
@@ -114,6 +130,7 @@
|
|||||||
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
||||||
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
||||||
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
|
- 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 是旧前端进程,应清理旧进程后重启。
|
- 处理:优先用 `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"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
|
- 验证:`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`。
|
- 关联:`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 成未登录态。
|
- 原因:推荐页 embedded 运行态会自动发起受保护写请求。若这些卡片级后台请求遇到 `401` 或 refresh 失败,默认请求层曾清空 access token 并广播全局 auth 事件,导致 `AuthGate` 重新 hydrate 成未登录态。
|
||||||
- 处理:推荐页自动运行态请求传 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`,并等 `canReadProtectedData` 为 true 后再启动;用户主动点击的受保护动作仍保留默认鉴权失败处理。
|
- 处理:推荐页自动运行态请求传 `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"`。
|
- 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。
|
||||||
- 关联:`src/services/apiClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
- 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`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 健康检查误超时
|
## Rust 冷编译导致 api-server 健康检查误超时
|
||||||
|
|
||||||
@@ -235,11 +254,11 @@
|
|||||||
|
|
||||||
## Rust 构建不要让不可用的 sccache 阻断 rustc
|
## Rust 构建不要让不可用的 sccache 阻断 rustc
|
||||||
|
|
||||||
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`,真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
|
- 现象: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 设置了 `RUSTC_WRAPPER=sccache`,但当前 Windows/Linux agent 上没有可执行的 `sccache`,或 PATH 中的 `sccache` shim 损坏。
|
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。
|
||||||
- 处理:本地临时排障可执行 `Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue` 后重跑 Cargo;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
|
- 处理:本地临时排障可在 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` 能输出版本;`cargo` 不再尝试调用不可用的 `sccache`;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
- 验证:`rustc -Vv` 能输出版本;清空 wrapper 后 `cargo check --target=wasm32-unknown-unknown --release` 能通过;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
|
||||||
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
- 关联:`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 / 一体化脚本
|
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
|
||||||
|
|
||||||
@@ -256,3 +275,11 @@
|
|||||||
- 处理:Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`;API 和领域构造层拒绝非 `User`。
|
- 处理:Admin 任务配置页不展示范围选择,保存时固定 `scopeKind: 'user'`;API 和领域构造层拒绝非 `User`。
|
||||||
- 验证:非 `user` scope 返回错误;相关测试覆盖 `Site` / `Module` / `Work` 被拒绝。
|
- 验证:非 `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`。
|
- 关联:`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`。
|
||||||
|
|||||||
8
deploy/env/api-server.env.example
vendored
8
deploy/env/api-server.env.example
vendored
@@ -37,12 +37,16 @@ GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
|
|||||||
|
|
||||||
APIMART_BASE_URL=
|
APIMART_BASE_URL=
|
||||||
APIMART_API_KEY=
|
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_API_KEY=
|
||||||
|
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||||
VECTOR_ENGINE_AUDIO_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_API_KEY=
|
||||||
VOLCENGINE_SPEECH_APP_ID=
|
VOLCENGINE_SPEECH_APP_ID=
|
||||||
VOLCENGINE_SPEECH_ACCESS_KEY=
|
VOLCENGINE_SPEECH_ACCESS_KEY=
|
||||||
|
|||||||
@@ -100,3 +100,13 @@
|
|||||||
3. 发现
|
3. 发现
|
||||||
|
|
||||||
创作 Tab 必须位于中间,并使用原推荐 Tab 的星光图标,保持几何和视觉上的主行动入口。推荐 Tab 改用游戏手柄图标,避免与创作图标重复。
|
创作 Tab 必须位于中间,并使用原推荐 Tab 的星光图标,保持几何和视觉上的主行动入口。推荐 Tab 改用游戏手柄图标,避免与创作图标重复。
|
||||||
|
|
||||||
|
## 9. 2026-05-08 新用户默认发现与推荐门禁补充
|
||||||
|
|
||||||
|
未登录新用户首次进入平台时默认落在“发现”Tab,不再直接进入“推荐”Tab 的内嵌运行态。
|
||||||
|
|
||||||
|
- 未登录用户点击底部或侧边栏“推荐”Tab 时,页面可切到推荐封面预览态,同时打开登录弹窗。
|
||||||
|
- 未登录状态下推荐页只展示当前推荐作品封面,不启动作品运行态,不展示推荐作品信息区。
|
||||||
|
- 未登录用户点击推荐页封面时,再次打开同一个登录弹窗;登录成功后由既有受保护动作继续进入作品详情或玩法入口。
|
||||||
|
- 未登录状态下点击“下一个”只切换下一张推荐封面,不触发登录弹窗,也不启动玩法。
|
||||||
|
- 已登录用户继续沿用推荐页内嵌运行态、上下滑切换和底部“下一个”行为。
|
||||||
|
|||||||
@@ -34,16 +34,21 @@ GENARRATIVE_LLM_BASE_URL=
|
|||||||
GENARRATIVE_LLM_API_KEY=
|
GENARRATIVE_LLM_API_KEY=
|
||||||
GENARRATIVE_LLM_MODEL=
|
GENARRATIVE_LLM_MODEL=
|
||||||
|
|
||||||
# APIMart / OpenAI 兼容图片网关
|
# APIMart / OpenAI 兼容 Responses 文本网关
|
||||||
APIMART_BASE_URL=
|
APIMART_BASE_URL=
|
||||||
APIMART_API_KEY=
|
APIMART_API_KEY=
|
||||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
|
||||||
|
|
||||||
# VectorEngine / Suno / Vidu 音频生成网关
|
# VectorEngine / GPT-image-2 / Suno / Vidu 生成网关
|
||||||
VECTOR_ENGINE_BASE_URL=
|
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
|
||||||
VECTOR_ENGINE_API_KEY=
|
VECTOR_ENGINE_API_KEY=
|
||||||
|
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
|
||||||
VECTOR_ENGINE_AUDIO_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
|
# 火山引擎豆包语音 ASR / TTS
|
||||||
VOLCENGINE_SPEECH_API_KEY=
|
VOLCENGINE_SPEECH_API_KEY=
|
||||||
VOLCENGINE_SPEECH_APP_ID=
|
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_API_KEY / VOLCENGINE_API_KEY
|
||||||
VOLCENGINE_SPEECH_APP_ID / VOLCENGINE_ACCESS_KEY_ID
|
VOLCENGINE_SPEECH_APP_ID / VOLCENGINE_ACCESS_KEY_ID
|
||||||
VOLCENGINE_SPEECH_ACCESS_KEY / VOLCENGINE_SECRET_ACCESS_KEY
|
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。
|
3. 文本 LLM provider 为 `ark` 且未配置 `GENARRATIVE_LLM_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
|
||||||
4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
|
4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
|
||||||
5. 具体模型名缺失时不在配置层伪造默认模型,调用到对应能力时由下游配置校验返回缺配置错误。
|
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` 只暴露平台鉴权后的代理路由,不向前端返回任何密钥字段。
|
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 配置。
|
||||||
|
|
||||||
## 示例文件
|
## 示例文件
|
||||||
|
|
||||||
|
|||||||
@@ -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`。
|
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 端口。
|
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 端口。
|
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 页面判断登录入口状态。
|
6. 如果 `3000` 仍然返回 `500`,先确认浏览器是不是还开着旧的前端进程。当前脚本如果因为端口占用漂移到 `3001` / `3002`,应直接关掉旧进程后重启,而不是继续用旧的 3000 页面判断登录入口状态。
|
||||||
|
|||||||
128
docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md
Normal file
128
docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md
Normal file
@@ -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,默认验证不自动调用真实生成接口。
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
|
|
||||||
### 1. 图片生成
|
### 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` 路由。
|
2. 历史 `original` 或空模型值只做兼容输入,不再进入 DashScope 原模型链路,统一按 `gpt-image-2` 路由。
|
||||||
3. 文生图和参考图生图共用同一个正方形尺寸口径,禁止一条链路仍生成竖屏或横版图。
|
3. 文生图和参考图生图共用同一个正方形尺寸口径,禁止一条链路仍生成竖屏或横版图。
|
||||||
4. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。
|
4. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。
|
||||||
5. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免上游把超长 prompt 判为“请求参数不合法”。
|
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。
|
7. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。
|
||||||
8. 光点预扣失败属于钱包或 SpacetimeDB 服务链路错误,不得映射成 `400 BAD_REQUEST`。除余额不足返回 `409 CONFLICT` 外,其余预扣异常统一按上游/服务错误暴露,避免生成页误提示“请求参数不合法”。
|
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 正方形拼图关卡`。
|
2. 图片提示词包含 `1:1 正方形拼图关卡`。
|
||||||
3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、`避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。
|
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`。
|
5. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。
|
||||||
6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。
|
6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。
|
||||||
7. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。
|
7. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。
|
||||||
|
|||||||
@@ -56,3 +56,9 @@
|
|||||||
3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。
|
3. 标签少于 `3` 个时,发布弹窗明确提示“正式标签数量必须在 3 到 6 之间”。
|
||||||
4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。
|
4. 标签补到 `3~6` 个后,无需刷新页面即可通过前端发布校验。
|
||||||
5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。
|
5. 结果页顶部能看到轻量自动保存状态,不额外堆叠说明文案。
|
||||||
|
|
||||||
|
## 2026-05-09 发布失败提示补充
|
||||||
|
|
||||||
|
`publish_puzzle_work` 属于资产操作发布入口,按 `ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md` 会在发布 mutation 前预扣 `1` 枚光点。余额不足时后端返回 `409 CONFLICT`,响应 `details.message` 为 `光点余额不足`,这属于业务拒绝,不是拼图发布接口不可用。
|
||||||
|
|
||||||
|
结果页发布弹窗必须在用户点击发布后继续展示后端错误原因,不能只把错误写到弹窗背后的页面 banner。这样余额不足、SpacetimeDB 发布门禁或其他后端业务错误都会在当前独立发布面板中直接可见。
|
||||||
|
|||||||
@@ -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 差异。
|
- [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 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
- [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 单向流式的后端代理、环境变量、协议帧和验收边界。
|
- [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 资产回写和前端弹层交互边界。
|
- [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 校验和前端预览/提交边界。
|
- [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 流水线拆分计划和首批部署骨架。
|
- [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):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
|
- [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_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 请求超时的根因,并将场景骨架批次收敛为单场景生成。
|
- [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` 代理补齐与回归测试。
|
- [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 链路。
|
- [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 链路。
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
登录成功进入平台推荐页后,推荐页会自动加载一个公开作品并启动嵌入式运行态。实际联调中出现过:作品刚加载出来,前端又瞬间回到未登录状态;停留在其他页面,或推荐页没有成功加载出作品时不会复现。
|
登录成功进入平台推荐页后,推荐页会自动加载一个公开作品并启动嵌入式运行态。实际联调中出现过:作品刚加载出来,前端又瞬间回到未登录状态;停留在其他页面,或推荐页没有成功加载出作品时不会复现。
|
||||||
|
|
||||||
|
后续复测又发现:登录成功后,从推荐页点进拼图公开作品详情并启动完整拼图运行态,也可能在开局或通关后瞬间退回未登录。两类现象的底层问题一致,都是玩法/展示层局部请求把 `401` 扩散成全局鉴权事件。
|
||||||
|
|
||||||
## 根因
|
## 根因
|
||||||
|
|
||||||
推荐页首屏的作品运行态启动是后台自动副作用,不是用户主动点击的账号操作。它会触发多条受保护请求,例如:
|
推荐页首屏的作品运行态启动是后台自动副作用,不是用户主动点击的账号操作。它会触发多条受保护请求,例如:
|
||||||
@@ -16,6 +18,10 @@
|
|||||||
|
|
||||||
这些请求一旦遇到本地代理错配、后端短暂不可用或 token 刷新失败,原请求层会按普通受保护请求处理 `401`,清空 access token 并广播全局鉴权变更。`AuthGate` 收到事件后重新 hydrate,于是当前用户界面被切回未登录态。
|
这些请求一旦遇到本地代理错配、后端短暂不可用或 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。
|
1. `apiClient` 增加 `clearAuthOnUnauthorized` 选项,允许局部请求在 `401` 时不清空全局 token。
|
||||||
2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。
|
2. 推荐页嵌入式运行态请求统一传入 `skipRefresh: true`、`notifyAuthStateChange: false`、`clearAuthOnUnauthorized: false`。
|
||||||
3. 推荐页自动启动作品前必须满足 `canReadProtectedData`,避免 `AuthGate` 仍在恢复阶段就提前发起受保护写请求。
|
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"`
|
2. `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`
|
||||||
3. `npm run typecheck`
|
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 check:encoding`
|
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`
|
||||||
|
|
||||||
## 关联文件
|
## 关联文件
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RPG 创作链路里有两类正式图片资产需要统一模型:
|
|||||||
1. 角色主图候选生成。
|
1. 角色主图候选生成。
|
||||||
2. 场景幕背景图生成。
|
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
|
```text
|
||||||
POST {APIMART_BASE_URL}/images/generations
|
POST {VECTOR_ENGINE_BASE_URL}/v1/images/generations
|
||||||
Authorization: Bearer {APIMART_API_KEY}
|
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||||
model = gpt-image-2
|
model = gpt-image-2-all
|
||||||
```
|
```
|
||||||
|
|
||||||
请求体统一包含:
|
请求体统一包含:
|
||||||
@@ -36,16 +36,15 @@ model = gpt-image-2
|
|||||||
1. `model`
|
1. `model`
|
||||||
2. `prompt`
|
2. `prompt`
|
||||||
3. `n`
|
3. `n`
|
||||||
4. `official_fallback = true`
|
4. `size`
|
||||||
5. `size`
|
5. 有参考图时增加 `image`
|
||||||
6. 有参考图时增加 `image_urls`
|
|
||||||
|
|
||||||
尺寸归一规则:
|
尺寸归一规则:
|
||||||
|
|
||||||
1. `1024*1024`、`1024x1024`、`1:1` -> `1:1`
|
1. `1024*1024`、`1024x1024`、`1:1` -> `1024x1024`
|
||||||
2. `1280*720`、`1600*900`、`16:9` -> `16:9`
|
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
|
```text
|
||||||
APIMART_BASE_URL=https://api.apimart.ai/v1
|
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
|
||||||
APIMART_API_KEY=...
|
VECTOR_ENGINE_API_KEY=...
|
||||||
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
|
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`。
|
1. 角色主图生成请求上游 `model` 为 `gpt-image-2-all`,且不携带 `official_fallback`。
|
||||||
2. 场景图生成请求上游 `model` 为 `gpt-image-2`,且携带 `official_fallback = true`。
|
2. 场景图生成请求上游 `model` 为 `gpt-image-2-all`,且不携带 `official_fallback`。
|
||||||
3. 旧前端或历史草稿传 `wan2.7-image-pro` 时不会回退旧模型。
|
3. 旧前端或历史草稿传 `wan2.7-image-pro` 时不会回退旧模型。
|
||||||
4. 场景参考图生成仍能把参考图 Data URL 放入 `image_urls`。
|
4. 场景参考图生成仍能把参考图 Data URL 放入 `image`。
|
||||||
5. 角色主图生成后仍执行原有 PNG 透明背景处理与 OSS 写入。
|
5. 角色主图生成后仍执行原有 PNG 透明背景处理与 OSS 写入。
|
||||||
6. `cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml` 通过。
|
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` 通过。
|
7. `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 通过。
|
||||||
|
|||||||
@@ -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 通信失败时脚本应打印降级提示并继续真实构建。
|
||||||
@@ -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/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`;当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`。启动参数、探活和 root-dir 占用判定都使用同一个 `.spacetimedb/`。这样可以把发布包与部署机全局 `~/.spacetime` 隔离,避免后续人工 `spacetime login` 影响本地发布包。但如果旧 `.spacetimedb/` 已经由另一个身份创建,仍需要按第 4 节处理。
|
`spacetime start` 不再额外设置 `--data-dir`,启动前会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/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. 排查与处理
|
## 4. 排查与处理
|
||||||
|
|
||||||
先在执行 `start.sh` 的同一台机器、同一用户下确认身份:
|
先在执行 `start.sh` 的同一台机器、同一用户下确认身份:
|
||||||
@@ -48,6 +50,16 @@ spacetime --root-dir ./.spacetimedb login show
|
|||||||
spacetime --root-dir ./.spacetimedb list --server http://127.0.0.1:3101
|
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
|
```bash
|
||||||
|
|||||||
@@ -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`。
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 86 KiB |
75
packages/shared/src/contracts/hyper3d.ts
Normal file
75
packages/shared/src/contracts/hyper3d.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export type * from './creativeAgent';
|
export type * from './creativeAgent';
|
||||||
|
export type * from './hyper3d';
|
||||||
export type * from './puzzleCreativeTemplate';
|
export type * from './puzzleCreativeTemplate';
|
||||||
export type * from './visualNovel';
|
export type * from './visualNovel';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './contracts/common';
|
|||||||
export type * from './contracts/creationAgentDocumentInput';
|
export type * from './contracts/creationAgentDocumentInput';
|
||||||
export type * from './contracts/creativeAgent';
|
export type * from './contracts/creativeAgent';
|
||||||
export type * from './contracts/customWorldAgent';
|
export type * from './contracts/customWorldAgent';
|
||||||
|
export type * from './contracts/hyper3d';
|
||||||
export * from './contracts/match3dAgent';
|
export * from './contracts/match3dAgent';
|
||||||
export * from './contracts/match3dRuntime';
|
export * from './contracts/match3dRuntime';
|
||||||
export * from './contracts/match3dWorks';
|
export * from './contracts/match3dWorks';
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ is_spacetime_ready() {
|
|||||||
local server="$1"
|
local server="$1"
|
||||||
local output
|
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
|
[[ "${output}" == *"Server is online:"* ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -345,6 +345,48 @@ prepare_migration_bootstrap_secret() {
|
|||||||
echo "[dev:rust] 迁移引导密钥: ${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)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||||
@@ -564,6 +606,7 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
|||||||
# 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口;
|
# 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口;
|
||||||
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
|
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
|
||||||
printf '\n' | spacetime \
|
printf '\n' | spacetime \
|
||||||
|
--root-dir="${SPACETIME_ROOT_DIR}" \
|
||||||
start \
|
start \
|
||||||
--data-dir "${SPACETIME_DATA_DIR}" \
|
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||||
@@ -601,7 +644,9 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
|||||||
cd "${SERVER_RS_DIR}"
|
cd "${SERVER_RS_DIR}"
|
||||||
# spacetime publish 会在内部调用 Cargo;从 server-rs 目录执行,确保读取
|
# spacetime publish 会在内部调用 Cargo;从 server-rs 目录执行,确保读取
|
||||||
# server-rs/.cargo/config.toml 中的 sccache/linker 配置,并复用同一套 target 缓存。
|
# 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
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,30 @@ loadEnvFile(resolve(repoRoot, '.env'), fileEnv);
|
|||||||
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
|
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
|
||||||
loadEnvFile(resolve(repoRoot, '.env.secrets.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 = [
|
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:3100',
|
||||||
|
'http://127.0.0.1:8082',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return Array.from(new Set(candidates));
|
return Array.from(new Set(candidates));
|
||||||
@@ -70,39 +86,30 @@ async function isTargetReachable(target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveRuntimeTarget() {
|
async function resolveRuntimeTarget() {
|
||||||
const candidates = buildTargetCandidates();
|
const configuredTarget = resolveConfiguredTarget();
|
||||||
const reachableTargets = [];
|
|
||||||
|
|
||||||
for (const target of candidates) {
|
if (configuredTarget) {
|
||||||
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) {
|
|
||||||
return {
|
return {
|
||||||
target: reachableTargets[0],
|
target: configuredTarget,
|
||||||
fallbackUsed: true,
|
fallbackUsed: false,
|
||||||
|
targetUnavailable: !(await isTargetReachable(configuredTarget)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const target of buildFallbackCandidates()) {
|
||||||
|
if (await isTargetReachable(target)) {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
fallbackUsed: true,
|
||||||
|
targetUnavailable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
target:
|
target: 'http://127.0.0.1:3100',
|
||||||
fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET ||
|
|
||||||
fileEnv.RUST_SERVER_TARGET ||
|
|
||||||
fileEnv.GENARRATIVE_API_TARGET ||
|
|
||||||
`http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
|
|
||||||
fallbackUsed: false,
|
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 = {
|
const mergedEnv = {
|
||||||
...fileEnv,
|
...fileEnv,
|
||||||
RUST_SERVER_TARGET: runtimeTarget.target,
|
RUST_SERVER_TARGET: runtimeTarget.target,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ axum = { workspace = true, features = ["ws"] }
|
|||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
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 }
|
webp = { workspace = true }
|
||||||
module-ai = { workspace = true }
|
module-ai = { workspace = true }
|
||||||
module-assets = { workspace = true, features = ["server-service"] }
|
module-assets = { workspace = true, features = ["server-service"] }
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
health::health_check,
|
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,
|
llm::proxy_llm_chat_completions,
|
||||||
login_options::auth_login_options,
|
login_options::auth_login_options,
|
||||||
logout::logout,
|
logout::logout,
|
||||||
@@ -166,6 +170,7 @@ use crate::{
|
|||||||
|
|
||||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 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 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
pub fn build_router(state: AppState) -> Router {
|
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),
|
post(resolve_role_asset_workflow).put(put_role_asset_workflow),
|
||||||
)
|
)
|
||||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
.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(
|
.route(
|
||||||
"/api/assets/history",
|
"/api/assets/history",
|
||||||
get(get_asset_history).route_layer(middleware::from_fn_with_state(
|
get(get_asset_history).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -96,10 +96,13 @@ pub struct AppConfig {
|
|||||||
pub dashscope_image_request_timeout_ms: u64,
|
pub dashscope_image_request_timeout_ms: u64,
|
||||||
pub apimart_base_url: String,
|
pub apimart_base_url: String,
|
||||||
pub apimart_api_key: Option<String>,
|
pub apimart_api_key: Option<String>,
|
||||||
pub apimart_image_request_timeout_ms: u64,
|
|
||||||
pub vector_engine_base_url: String,
|
pub vector_engine_base_url: String,
|
||||||
pub vector_engine_api_key: Option<String>,
|
pub vector_engine_api_key: Option<String>,
|
||||||
|
pub vector_engine_image_request_timeout_ms: u64,
|
||||||
pub vector_engine_audio_request_timeout_ms: u64,
|
pub vector_engine_audio_request_timeout_ms: u64,
|
||||||
|
pub hyper3d_base_url: String,
|
||||||
|
pub hyper3d_api_key: Option<String>,
|
||||||
|
pub hyper3d_model_request_timeout_ms: u64,
|
||||||
pub volcengine_speech_api_key: Option<String>,
|
pub volcengine_speech_api_key: Option<String>,
|
||||||
pub volcengine_speech_app_id: Option<String>,
|
pub volcengine_speech_app_id: Option<String>,
|
||||||
pub volcengine_speech_access_key: Option<String>,
|
pub volcengine_speech_access_key: Option<String>,
|
||||||
@@ -203,10 +206,13 @@ impl Default for AppConfig {
|
|||||||
dashscope_image_request_timeout_ms: 150_000,
|
dashscope_image_request_timeout_ms: 150_000,
|
||||||
apimart_base_url: String::new(),
|
apimart_base_url: String::new(),
|
||||||
apimart_api_key: None,
|
apimart_api_key: None,
|
||||||
apimart_image_request_timeout_ms: 180_000,
|
|
||||||
vector_engine_base_url: String::new(),
|
vector_engine_base_url: String::new(),
|
||||||
vector_engine_api_key: None,
|
vector_engine_api_key: None,
|
||||||
|
vector_engine_image_request_timeout_ms: 180_000,
|
||||||
vector_engine_audio_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_api_key: None,
|
||||||
volcengine_speech_app_id: None,
|
volcengine_speech_app_id: None,
|
||||||
volcengine_speech_access_key: None,
|
volcengine_speech_access_key: None,
|
||||||
@@ -567,12 +573,6 @@ impl AppConfig {
|
|||||||
|
|
||||||
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
|
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"])
|
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;
|
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"]);
|
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) =
|
if let Some(vector_engine_audio_request_timeout_ms) =
|
||||||
read_first_positive_u64_env(&["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;
|
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 =
|
config.volcengine_speech_api_key =
|
||||||
read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]);
|
read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]);
|
||||||
config.volcengine_speech_app_id =
|
config.volcengine_speech_app_id =
|
||||||
@@ -910,6 +931,7 @@ mod tests {
|
|||||||
assert!(config.apimart_base_url.is_empty());
|
assert!(config.apimart_base_url.is_empty());
|
||||||
assert!(config.vector_engine_base_url.is_empty());
|
assert!(config.vector_engine_base_url.is_empty());
|
||||||
assert!(config.ark_character_video_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.ark_character_video_model.is_empty());
|
||||||
assert!(config.dashscope_scene_image_model.is_empty());
|
assert!(config.dashscope_scene_image_model.is_empty());
|
||||||
assert!(config.dashscope_reference_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("GENARRATIVE_LLM_MODEL");
|
||||||
std::env::remove_var("APIMART_BASE_URL");
|
std::env::remove_var("APIMART_BASE_URL");
|
||||||
std::env::remove_var("VECTOR_ENGINE_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_SCENE_IMAGE_MODEL");
|
||||||
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
|
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
|
||||||
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
|
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
|
||||||
@@ -949,8 +973,10 @@ mod tests {
|
|||||||
"https://llm.internal.example/v1",
|
"https://llm.internal.example/v1",
|
||||||
);
|
);
|
||||||
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
|
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("APIMART_BASE_URL", "https://responses.internal.example/v1");
|
||||||
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://audio.internal.example");
|
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_SCENE_IMAGE_MODEL", "scene-model");
|
||||||
std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model");
|
std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model");
|
||||||
std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-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_provider, LlmProvider::OpenAiCompatible);
|
||||||
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
|
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
|
||||||
assert_eq!(config.llm_model, "internal-text-model");
|
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!(
|
assert_eq!(
|
||||||
config.vector_engine_base_url,
|
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_scene_image_model, "scene-model");
|
||||||
assert_eq!(config.dashscope_reference_image_model, "reference-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("GENARRATIVE_LLM_MODEL");
|
||||||
std::env::remove_var("APIMART_BASE_URL");
|
std::env::remove_var("APIMART_BASE_URL");
|
||||||
std::env::remove_var("VECTOR_ENGINE_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_SCENE_IMAGE_MODEL");
|
||||||
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
|
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
|
||||||
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
|
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
|
||||||
|
|||||||
1052
server-rs/crates/api-server/src/hyper3d_generation.rs
Normal file
1052
server-rs/crates/api-server/src/hyper3d_generation.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@ mod custom_world_rpg_draft_prompts;
|
|||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
|
mod hyper3d_generation;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod llm_model_routing;
|
mod llm_model_routing;
|
||||||
mod login_options;
|
mod login_options;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use reqwest::header;
|
use reqwest::header;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
use crate::{http_error::AppError, state::AppState};
|
use crate::{http_error::AppError, state::AppState};
|
||||||
|
|
||||||
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct OpenAiImageSettings {
|
pub(crate) struct OpenAiImageSettings {
|
||||||
@@ -31,37 +32,41 @@ pub(crate) struct DownloadedOpenAiImage {
|
|||||||
pub extension: String,
|
pub extension: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中文注释:RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
|
// 中文注释:RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
|
||||||
pub(crate) fn require_openai_image_settings(
|
pub(crate) fn require_openai_image_settings(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<OpenAiImageSettings, AppError> {
|
) -> Result<OpenAiImageSettings, AppError> {
|
||||||
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() {
|
if base_url.is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"reason": "APIMART_BASE_URL 未配置",
|
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let api_key = state
|
let api_key = state
|
||||||
.config
|
.config
|
||||||
.apimart_api_key
|
.vector_engine_api_key
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"reason": "APIMART_API_KEY 未配置",
|
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||||
}))
|
}))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(OpenAiImageSettings {
|
Ok(OpenAiImageSettings {
|
||||||
base_url: base_url.to_string(),
|
base_url: base_url.to_string(),
|
||||||
api_key: api_key.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()
|
.build()
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"),
|
"message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -97,11 +102,12 @@ pub(crate) async fn create_openai_image_generation(
|
|||||||
reference_images,
|
reference_images,
|
||||||
);
|
);
|
||||||
let response = http_client
|
let response = http_client
|
||||||
.post(format!("{}/images/generations", settings.base_url))
|
.post(vector_engine_images_generation_url(settings))
|
||||||
.header(
|
.header(
|
||||||
header::AUTHORIZATION,
|
header::AUTHORIZATION,
|
||||||
format!("Bearer {}", settings.api_key),
|
format!("Bearer {}", settings.api_key),
|
||||||
)
|
)
|
||||||
|
.header(header::ACCEPT, "application/json")
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.json(&request_body)
|
.json(&request_body)
|
||||||
.send()
|
.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 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);
|
let image_urls = extract_image_urls(&response_json.payload);
|
||||||
if !image_urls.is_empty() {
|
if !image_urls.is_empty() {
|
||||||
return download_images_from_urls(
|
let mut generated =
|
||||||
http_client,
|
download_images_from_urls(http_client, generation_id, image_urls, candidate_count)
|
||||||
format!("apimart-{}", current_utc_micros()),
|
.await?;
|
||||||
image_urls,
|
generated.actual_prompt = actual_prompt;
|
||||||
candidate_count,
|
return Ok(generated);
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
let b64_images = extract_b64_images(&response_json.payload);
|
let b64_images = extract_b64_images(&response_json.payload);
|
||||||
if !b64_images.is_empty() {
|
if !b64_images.is_empty() {
|
||||||
return Ok(images_from_base64(
|
let mut generated = images_from_base64(generation_id, b64_images, candidate_count);
|
||||||
format!("apimart-{}", current_utc_micros()),
|
generated.actual_prompt = actual_prompt;
|
||||||
b64_images,
|
return Ok(generated);
|
||||||
candidate_count,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
|
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"provider": "apimart",
|
"message": format!("{failure_context}:VectorEngine 未返回图片地址"),
|
||||||
"message": format!("{failure_context}:上游未返回 task_id 或图片"),
|
})))
|
||||||
}))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
wait_openai_generated_images(
|
|
||||||
http_client,
|
|
||||||
settings,
|
|
||||||
task_id.as_str(),
|
|
||||||
candidate_count,
|
|
||||||
failure_context,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_openai_image_request_body(
|
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([
|
let mut body = Map::from_iter([
|
||||||
(
|
(
|
||||||
"model".to_string(),
|
"model".to_string(),
|
||||||
Value::String(GPT_IMAGE_2_MODEL.to_string()),
|
Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"prompt".to_string(),
|
"prompt".to_string(),
|
||||||
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
|
Value::String(build_prompt_with_negative(prompt, negative_prompt)),
|
||||||
),
|
),
|
||||||
("n".to_string(), json!(candidate_count.clamp(1, 4))),
|
("n".to_string(), json!(candidate_count.clamp(1, 4))),
|
||||||
("official_fallback".to_string(), Value::Bool(true)),
|
|
||||||
(
|
(
|
||||||
"size".to_string(),
|
"size".to_string(),
|
||||||
Value::String(normalize_image_size(size)),
|
Value::String(normalize_image_size(size)),
|
||||||
@@ -185,7 +179,7 @@ pub(crate) fn build_openai_image_request_body(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if !reference_images.is_empty() {
|
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)
|
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 {
|
fn normalize_image_size(size: &str) -> String {
|
||||||
match size.trim() {
|
match size.trim() {
|
||||||
"1024*1024" | "1024x1024" | "1:1" => "1:1",
|
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
|
||||||
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9",
|
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9"
|
||||||
|
| "1536x1024" | "2048x1152" | "2k" => "1536x1024",
|
||||||
|
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
|
||||||
value if !value.is_empty() => value,
|
value if !value.is_empty() => value,
|
||||||
_ => "1:1",
|
_ => "1024x1024",
|
||||||
}
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_openai_generated_images(
|
|
||||||
http_client: &reqwest::Client,
|
|
||||||
settings: &OpenAiImageSettings,
|
|
||||||
task_id: &str,
|
|
||||||
candidate_count: u32,
|
|
||||||
failure_context: &str,
|
|
||||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
|
||||||
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(
|
async fn download_images_from_urls(
|
||||||
http_client: &reqwest::Client,
|
http_client: &reqwest::Client,
|
||||||
task_id: String,
|
task_id: String,
|
||||||
@@ -377,7 +278,7 @@ pub(crate) async fn download_remote_image(
|
|||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
return Err(
|
return Err(
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": "下载生成图片失败",
|
"message": "下载生成图片失败",
|
||||||
"status": status.as_u16(),
|
"status": status.as_u16(),
|
||||||
})),
|
})),
|
||||||
@@ -400,7 +301,7 @@ fn parse_json_payload(
|
|||||||
.map(|payload| ParsedJsonPayload { payload })
|
.map(|payload| ParsedJsonPayload { payload })
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": format!("{failure_context}:解析响应失败:{error}"),
|
"message": format!("{failure_context}:解析响应失败:{error}"),
|
||||||
"rawExcerpt": truncate_raw(raw_text),
|
"rawExcerpt": truncate_raw(raw_text),
|
||||||
}))
|
}))
|
||||||
@@ -409,7 +310,7 @@ fn parse_json_payload(
|
|||||||
|
|
||||||
fn map_openai_image_request_error(message: String) -> AppError {
|
fn map_openai_image_request_error(message: String) -> AppError {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": message,
|
"message": message,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -421,14 +322,14 @@ fn map_openai_image_upstream_error(
|
|||||||
) -> AppError {
|
) -> AppError {
|
||||||
let message = parse_api_error_message(raw_text, failure_context);
|
let message = parse_api_error_message(raw_text, failure_context);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
provider = "apimart",
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
upstream_status,
|
upstream_status,
|
||||||
raw_excerpt = %truncate_raw(raw_text),
|
raw_excerpt = %truncate_raw(raw_text),
|
||||||
message,
|
message,
|
||||||
"APIMart 图片生成上游错误"
|
"VectorEngine 图片生成上游错误"
|
||||||
);
|
);
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
"provider": "apimart",
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
"message": message,
|
"message": message,
|
||||||
"upstreamStatus": upstream_status,
|
"upstreamStatus": upstream_status,
|
||||||
"rawExcerpt": truncate_raw(raw_text),
|
"rawExcerpt": truncate_raw(raw_text),
|
||||||
@@ -516,10 +417,10 @@ fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
|
|||||||
results.into_iter().next()
|
results.into_iter().next()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_task_id(payload: &Value) -> Option<String> {
|
fn extract_generation_id(payload: &Value) -> Option<String> {
|
||||||
find_first_string_by_key(payload, "task_id")
|
find_first_string_by_key(payload, "id")
|
||||||
.or_else(|| find_first_string_by_key(payload, "taskId"))
|
.or_else(|| find_first_string_by_key(payload, "created"))
|
||||||
.or_else(|| find_first_string_by_key(payload, "id"))
|
.or_else(|| find_first_string_by_key(payload, "request_id"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||||
@@ -542,6 +443,14 @@ fn extract_b64_images(payload: &Value) -> Vec<String> {
|
|||||||
values
|
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 {
|
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||||
let mime_type = content_type
|
let mime_type = content_type
|
||||||
.split(';')
|
.split(';')
|
||||||
@@ -602,7 +511,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let body = build_openai_image_request_body(
|
||||||
"雾海神殿",
|
"雾海神殿",
|
||||||
Some("文字,水印"),
|
Some("文字,水印"),
|
||||||
@@ -611,11 +520,11 @@ mod tests {
|
|||||||
&["data:image/png;base64,abcd".to_string()],
|
&["data:image/png;base64,abcd".to_string()],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
|
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||||
assert_eq!(body["size"], "16:9");
|
assert_eq!(body["size"], "1536x1024");
|
||||||
assert_eq!(body["n"], 2);
|
assert_eq!(body["n"], 2);
|
||||||
assert_eq!(body["official_fallback"], true);
|
assert!(body.get("official_fallback").is_none());
|
||||||
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
|
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
|
||||||
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
|
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -68,7 +68,6 @@ use spacetime_client::{
|
|||||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
||||||
@@ -80,6 +79,7 @@ use crate::{
|
|||||||
auth::AuthenticatedAccessToken,
|
auth::AuthenticatedAccessToken,
|
||||||
http_error::AppError,
|
http_error::AppError,
|
||||||
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
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,
|
platform_errors::map_oss_error,
|
||||||
prompt::puzzle::{
|
prompt::puzzle::{
|
||||||
draft::{
|
draft::{
|
||||||
@@ -112,8 +112,8 @@ const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
|
|||||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||||
const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1";
|
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||||
const PUZZLE_APIMART_GEMINI_RESOLUTION: &str = "1K";
|
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||||
|
|
||||||
pub async fn create_puzzle_agent_session(
|
pub async fn create_puzzle_agent_session(
|
||||||
@@ -941,7 +941,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
Err(error)
|
Err(error)
|
||||||
if should_skip_asset_operation_billing_for_connectivity(&error) =>
|
if should_skip_asset_operation_billing_for_connectivity(&error) =>
|
||||||
{
|
{
|
||||||
// 中文注释:APIMart/OSS 已生成真实图片时,Maincloud 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
|
// 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
session_id = %session.session_id,
|
session_id = %session.session_id,
|
||||||
@@ -3172,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
.map(|session| (session, false))
|
.map(|session| (session, false))
|
||||||
.or_else(|error| {
|
.or_else(|error| {
|
||||||
if is_spacetimedb_connectivity_app_error(&error) {
|
if is_spacetimedb_connectivity_app_error(&error) {
|
||||||
// 中文注释:首图已落 OSS 时,Maincloud 短暂不可用先返回本地快照,避免整次 APIMart 生图被判失败。
|
// 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
session_id = %compiled_session.session_id,
|
session_id = %compiled_session.session_id,
|
||||||
@@ -3271,7 +3271,7 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
|||||||
&target_level.picture_description,
|
&target_level.picture_description,
|
||||||
&draft.summary,
|
&draft.summary,
|
||||||
);
|
);
|
||||||
// 中文注释:关闭 AI 重绘时不请求 APIMart,也不进入光点扣费流程;上传图直接成为首关正式图候选。
|
// 中文注释:关闭 AI 重绘时不请求 VectorEngine,也不进入光点扣费流程;上传图直接成为首关正式图候选。
|
||||||
let candidate_id = format!(
|
let candidate_id = format!(
|
||||||
"{}-candidate-{}",
|
"{}-candidate-{}",
|
||||||
compiled_session.session_id,
|
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 {
|
fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
||||||
let message = error.to_string();
|
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")
|
||||||
|| message.contains("APIMART")
|
|| message.contains("APIMART")
|
||||||
{
|
{
|
||||||
"apimart"
|
VECTOR_ENGINE_PROVIDER
|
||||||
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
} else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") {
|
||||||
"puzzle-assets"
|
"puzzle-assets"
|
||||||
} else {
|
} else {
|
||||||
"spacetimedb"
|
"spacetimedb"
|
||||||
};
|
};
|
||||||
let status = if provider == "apimart"
|
let status = if provider == VECTOR_ENGINE_PROVIDER
|
||||||
&& (message.contains("APIMART_API_KEY")
|
&& (message.contains("VECTOR_ENGINE_API_KEY")
|
||||||
|
|| message.contains("VECTOR_ENGINE_BASE_URL")
|
||||||
|
|| message.contains("APIMART_API_KEY")
|
||||||
|| message.contains("APIMART_BASE_URL")
|
|| message.contains("APIMART_BASE_URL")
|
||||||
|| message.contains("未配置"))
|
|| message.contains("未配置"))
|
||||||
{
|
{
|
||||||
@@ -3899,6 +3904,9 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError {
|
|||||||
} else if matches!(error, SpacetimeClientError::Runtime(_))
|
} else if matches!(error, SpacetimeClientError::Runtime(_))
|
||||||
&& (message.contains("生成")
|
&& (message.contains("生成")
|
||||||
|| message.contains("上游")
|
|| message.contains("上游")
|
||||||
|
|| message.contains("VectorEngine")
|
||||||
|
|| message.contains("vector-engine")
|
||||||
|
|| message.contains("VECTOR_ENGINE")
|
||||||
|| message.contains("APIMart")
|
|| message.contains("APIMart")
|
||||||
|| message.contains("apimart")
|
|| message.contains("apimart")
|
||||||
|| message.contains("APIMART")
|
|| message.contains("APIMART")
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ fn build_creative_agent_gpt5_client(
|
|||||||
config.apimart_base_url.clone(),
|
config.apimart_base_url.clone(),
|
||||||
api_key.to_string(),
|
api_key.to_string(),
|
||||||
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
|
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
|
||||||
config.apimart_image_request_timeout_ms,
|
config.llm_request_timeout_ms,
|
||||||
0,
|
0,
|
||||||
config.llm_retry_backoff_ms,
|
config.llm_retry_backoff_ms,
|
||||||
)?
|
)?
|
||||||
|
|||||||
169
server-rs/crates/shared-contracts/src/hyper3d.rs
Normal file
169
server-rs/crates/shared-contracts/src/hyper3d.rs
Normal file
@@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seed: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub geometry_file_format: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub material: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quality: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mesh_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub addons: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bbox_condition: Option<Vec<f32>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preview_render: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Hyper3dImageToModelRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_data_urls: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_urls: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub condition_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seed: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub geometry_file_format: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub material: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub quality: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mesh_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub addons: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bbox_condition: Option<Vec<f32>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preview_render: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
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<Hyper3dJobStatusPayload>,
|
||||||
|
pub raw: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Hyper3dJobStatusPayload {
|
||||||
|
pub uuid: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub progress: Option<f32>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Hyper3dDownloadFilePayload>,
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub mod big_fish;
|
|||||||
pub mod big_fish_works;
|
pub mod big_fish_works;
|
||||||
pub mod creation_agent_document_input;
|
pub mod creation_agent_document_input;
|
||||||
pub mod creative_agent;
|
pub mod creative_agent;
|
||||||
|
pub mod hyper3d;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod match3d_agent;
|
pub mod match3d_agent;
|
||||||
pub mod match3d_runtime;
|
pub mod match3d_runtime;
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ type PuzzleRuntimeReturnStage =
|
|||||||
| 'puzzle-gallery-detail'
|
| 'puzzle-gallery-detail'
|
||||||
| 'work-detail'
|
| 'work-detail'
|
||||||
| 'platform';
|
| 'platform';
|
||||||
|
type PuzzleRuntimeAuthMode = 'default' | 'isolated';
|
||||||
|
|
||||||
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
||||||
|
|
||||||
@@ -387,6 +388,8 @@ const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = {
|
|||||||
notifyAuthStateChange: false,
|
notifyAuthStateChange: false,
|
||||||
clearAuthOnUnauthorized: false,
|
clearAuthOnUnauthorized: false,
|
||||||
};
|
};
|
||||||
|
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
|
||||||
|
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
|
||||||
|
|
||||||
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||||
@@ -1568,6 +1571,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
useState<PuzzleDetailReturnTarget | null>(null);
|
useState<PuzzleDetailReturnTarget | null>(null);
|
||||||
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
||||||
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
||||||
|
const [puzzleRuntimeAuthMode, setPuzzleRuntimeAuthMode] =
|
||||||
|
useState<PuzzleRuntimeAuthMode>('default');
|
||||||
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
|
||||||
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
|
||||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||||
@@ -2230,6 +2235,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleWorks((current) => [response.item, ...current]);
|
setPuzzleWorks((current) => [response.item, ...current]);
|
||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleOnboardingDraft(null);
|
setPuzzleOnboardingDraft(null);
|
||||||
setPuzzleOnboardingPrompt('');
|
setPuzzleOnboardingPrompt('');
|
||||||
setPuzzleOnboardingPhase('input');
|
setPuzzleOnboardingPhase('input');
|
||||||
@@ -2268,10 +2274,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleOnboardingPhase('input');
|
setPuzzleOnboardingPhase('input');
|
||||||
setPuzzleOnboardingError(null);
|
setPuzzleOnboardingError(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
platformBootstrap.setPlatformTab('home');
|
platformBootstrap.setPlatformTab(authUi?.user ? 'home' : 'category');
|
||||||
setSelectionStage('platform');
|
setSelectionStage('platform');
|
||||||
}, [platformBootstrap, setSelectionStage]);
|
}, [authUi?.user, platformBootstrap, setSelectionStage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -2321,6 +2328,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
markPuzzleOnboardingSeen();
|
markPuzzleOnboardingSeen();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setPuzzleRun(startLocalPuzzleRun(item));
|
setPuzzleRun(startLocalPuzzleRun(item));
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleRuntimeReturnStage('platform');
|
setPuzzleRuntimeReturnStage('platform');
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
|
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
|
||||||
@@ -2953,6 +2961,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setPuzzleFormDraftPayload(null);
|
setPuzzleFormDraftPayload(null);
|
||||||
@@ -2997,6 +3006,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setPuzzleFormDraftPayload(null);
|
setPuzzleFormDraftPayload(null);
|
||||||
@@ -3137,6 +3147,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setIsPuzzleNextLevelGenerating(false);
|
setIsPuzzleNextLevelGenerating(false);
|
||||||
setPuzzleShelfError(null);
|
setPuzzleShelfError(null);
|
||||||
@@ -3283,6 +3294,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const leavePuzzleFlow = useCallback(() => {
|
const leavePuzzleFlow = useCallback(() => {
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setIsPuzzleNextLevelGenerating(false);
|
setIsPuzzleNextLevelGenerating(false);
|
||||||
setActiveCreativeAgentSessionId(null);
|
setActiveCreativeAgentSessionId(null);
|
||||||
@@ -3773,6 +3785,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
puzzleFlow.setSession(response.session);
|
puzzleFlow.setSession(response.session);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setActiveCreativeAgentSessionId(creativeAgentSession.sessionId);
|
setActiveCreativeAgentSessionId(creativeAgentSession.sessionId);
|
||||||
setCreativeDraftEditError(null);
|
setCreativeDraftEditError(null);
|
||||||
@@ -4007,7 +4020,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
detailItem?: PuzzleWorkSummary,
|
detailItem?: PuzzleWorkSummary,
|
||||||
mirrorErrorToPublicDetail = false,
|
mirrorErrorToPublicDetail = false,
|
||||||
levelId?: string | null,
|
levelId?: string | null,
|
||||||
options: { embedded?: boolean } = {},
|
options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {},
|
||||||
) => {
|
) => {
|
||||||
if (isPuzzleBusy) {
|
if (isPuzzleBusy) {
|
||||||
return false;
|
return false;
|
||||||
@@ -4023,14 +4036,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
profileId: item.profileId,
|
profileId: item.profileId,
|
||||||
levelId: levelId ?? null,
|
levelId: levelId ?? null,
|
||||||
};
|
};
|
||||||
const { run } = options.embedded
|
const authMode = options.embedded
|
||||||
? await startPuzzleRun(
|
? 'isolated'
|
||||||
startRunPayload,
|
: (options.authMode ?? 'default');
|
||||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
const { run } =
|
||||||
)
|
authMode === 'isolated'
|
||||||
: await startPuzzleRun(startRunPayload);
|
? await startPuzzleRun(
|
||||||
|
startRunPayload,
|
||||||
|
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startPuzzleRun(startRunPayload);
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleRun(run);
|
setPuzzleRun(run);
|
||||||
|
setPuzzleRuntimeAuthMode(authMode);
|
||||||
setPuzzleRuntimeReturnStage(returnStage);
|
setPuzzleRuntimeReturnStage(returnStage);
|
||||||
if (!options.embedded) {
|
if (!options.embedded) {
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
@@ -4255,6 +4273,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const run = startLocalPuzzleRun(item);
|
const run = startLocalPuzzleRun(item);
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleRun(run);
|
setPuzzleRun(run);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -4496,10 +4515,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
|
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
|
||||||
(response) => response.item,
|
(response) => response.item,
|
||||||
);
|
);
|
||||||
const { run } = await startPuzzleRun({
|
const startRunPayload = {
|
||||||
profileId: currentLevel.profileId,
|
profileId: currentLevel.profileId,
|
||||||
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
|
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
|
||||||
});
|
};
|
||||||
|
const { run } =
|
||||||
|
puzzleRuntimeAuthMode === 'isolated'
|
||||||
|
? await startPuzzleRun(
|
||||||
|
startRunPayload,
|
||||||
|
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
|
||||||
|
)
|
||||||
|
: await startPuzzleRun(startRunPayload);
|
||||||
setSelectedPuzzleDetail(detailItem);
|
setSelectedPuzzleDetail(detailItem);
|
||||||
puzzleRunRef.current = run;
|
puzzleRunRef.current = run;
|
||||||
setPuzzleRun(run);
|
setPuzzleRun(run);
|
||||||
@@ -4513,6 +4539,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}, [
|
}, [
|
||||||
isPuzzleBusy,
|
isPuzzleBusy,
|
||||||
puzzleRun,
|
puzzleRun,
|
||||||
|
puzzleRuntimeAuthMode,
|
||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
selectedPuzzleDetail,
|
selectedPuzzleDetail,
|
||||||
setIsPuzzleBusy,
|
setIsPuzzleBusy,
|
||||||
@@ -4618,7 +4645,16 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
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 }) => {
|
.then(({ run }) => {
|
||||||
setPuzzleRun((currentRun) => {
|
setPuzzleRun((currentRun) => {
|
||||||
if (!currentRun) {
|
if (!currentRun) {
|
||||||
@@ -4641,6 +4677,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
authUi?.user?.displayName,
|
authUi?.user?.displayName,
|
||||||
platformBootstrap,
|
platformBootstrap,
|
||||||
puzzleRun,
|
puzzleRun,
|
||||||
|
puzzleRuntimeAuthMode,
|
||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
setPuzzleError,
|
setPuzzleError,
|
||||||
]);
|
]);
|
||||||
@@ -4678,10 +4715,20 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
: getPuzzleGalleryDetail(targetProfileId).then(
|
: getPuzzleGalleryDetail(targetProfileId).then(
|
||||||
(response) => response.item,
|
(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([
|
const [{ run }, item] = await Promise.all([
|
||||||
advancePuzzleNextLevel(puzzleRun.runId, {
|
advancePromise,
|
||||||
targetProfileId,
|
|
||||||
}),
|
|
||||||
itemPromise,
|
itemPromise,
|
||||||
]);
|
]);
|
||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
@@ -4695,7 +4742,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
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);
|
setPuzzleRun(run);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||||
@@ -4708,6 +4762,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isPuzzleBusy,
|
isPuzzleBusy,
|
||||||
isPuzzleLeaderboardBusy,
|
isPuzzleLeaderboardBusy,
|
||||||
puzzleRun,
|
puzzleRun,
|
||||||
|
puzzleRuntimeAuthMode,
|
||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
selectedPuzzleDetail,
|
selectedPuzzleDetail,
|
||||||
setIsPuzzleBusy,
|
setIsPuzzleBusy,
|
||||||
@@ -4732,6 +4787,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
puzzleFlow.setSession(response.session);
|
puzzleFlow.setSession(response.session);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setSelectionStage('puzzle-result');
|
setSelectionStage('puzzle-result');
|
||||||
})
|
})
|
||||||
@@ -4777,6 +4833,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
: currentRun;
|
: currentRun;
|
||||||
puzzleRunRef.current = null;
|
puzzleRunRef.current = null;
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setActiveRecommendRuntimeKind(null);
|
setActiveRecommendRuntimeKind(null);
|
||||||
|
|
||||||
if (closedRun.currentLevel) {
|
if (closedRun.currentLevel) {
|
||||||
@@ -5596,34 +5653,36 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openRecommendGalleryDetail = useCallback(
|
const openRecommendGalleryDetail = useCallback(
|
||||||
(entry: PlatformPublicGalleryCard) => {
|
(entry: PlatformPublicGalleryCard) => {
|
||||||
if (isBigFishGalleryEntry(entry)) {
|
runProtectedAction(() => {
|
||||||
openPublicWorkDetail(entry);
|
if (isBigFishGalleryEntry(entry)) {
|
||||||
return;
|
openPublicWorkDetail(entry);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPuzzleGalleryEntry(entry)) {
|
if (isPuzzleGalleryEntry(entry)) {
|
||||||
void openPuzzlePublicWorkDetail(entry.profileId, {
|
void openPuzzlePublicWorkDetail(entry.profileId, {
|
||||||
tab: platformBootstrap.platformTab,
|
tab: platformBootstrap.platformTab,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMatch3DGalleryEntry(entry)) {
|
if (isMatch3DGalleryEntry(entry)) {
|
||||||
openPublicWorkDetail(entry);
|
openPublicWorkDetail(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSquareHoleGalleryEntry(entry)) {
|
if (isSquareHoleGalleryEntry(entry)) {
|
||||||
openPublicWorkDetail(entry);
|
openPublicWorkDetail(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVisualNovelGalleryEntry(entry)) {
|
if (isVisualNovelGalleryEntry(entry)) {
|
||||||
void openVisualNovelPublicWorkDetail(entry.profileId);
|
void openVisualNovelPublicWorkDetail(entry.profileId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void openRpgPublicWorkDetail(entry);
|
void openRpgPublicWorkDetail(entry);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
openPuzzlePublicWorkDetail,
|
openPuzzlePublicWorkDetail,
|
||||||
@@ -5631,6 +5690,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
openRpgPublicWorkDetail,
|
openRpgPublicWorkDetail,
|
||||||
openVisualNovelPublicWorkDetail,
|
openVisualNovelPublicWorkDetail,
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
|
runProtectedAction,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const openPuzzleDetail = useCallback(
|
const openPuzzleDetail = useCallback(
|
||||||
@@ -5673,6 +5733,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
async (item: PuzzleWorkSummary) => {
|
async (item: PuzzleWorkSummary) => {
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
if (!item.sourceSessionId?.trim()) {
|
if (!item.sourceSessionId?.trim()) {
|
||||||
if (item.publicationStatus === 'published') {
|
if (item.publicationStatus === 'published') {
|
||||||
@@ -5960,6 +6021,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
'work-detail',
|
'work-detail',
|
||||||
work,
|
work,
|
||||||
true,
|
true,
|
||||||
|
null,
|
||||||
|
{ authMode: 'isolated' },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6462,6 +6525,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
if (
|
if (
|
||||||
selectionStage !== 'platform' ||
|
selectionStage !== 'platform' ||
|
||||||
platformBootstrap.platformTab !== 'home' ||
|
platformBootstrap.platformTab !== 'home' ||
|
||||||
|
!platformBootstrap.isAuthenticated ||
|
||||||
!platformBootstrap.canReadProtectedData ||
|
!platformBootstrap.canReadProtectedData ||
|
||||||
platformBootstrap.isLoadingPlatform
|
platformBootstrap.isLoadingPlatform
|
||||||
) {
|
) {
|
||||||
@@ -6494,6 +6558,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isStartingRecommendEntry,
|
isStartingRecommendEntry,
|
||||||
platformBootstrap.canReadProtectedData,
|
platformBootstrap.canReadProtectedData,
|
||||||
platformBootstrap.isLoadingPlatform,
|
platformBootstrap.isLoadingPlatform,
|
||||||
|
platformBootstrap.isAuthenticated,
|
||||||
platformBootstrap.platformTab,
|
platformBootstrap.platformTab,
|
||||||
recommendRuntimeEntries,
|
recommendRuntimeEntries,
|
||||||
selectRecommendRuntimeEntry,
|
selectRecommendRuntimeEntry,
|
||||||
@@ -8594,6 +8659,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
selectedPuzzleDetail.profileId,
|
selectedPuzzleDetail.profileId,
|
||||||
'puzzle-gallery-detail',
|
'puzzle-gallery-detail',
|
||||||
selectedPuzzleDetail,
|
selectedPuzzleDetail,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
{ authMode: 'isolated' },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -442,6 +442,40 @@ describe('PuzzleResultView', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps publish dialog open and shows backend publish error', () => {
|
||||||
|
const onExecuteAction = vi.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<PuzzleResultView
|
||||||
|
session={createSession()}
|
||||||
|
onBack={() => {}}
|
||||||
|
onExecuteAction={onExecuteAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||||
|
fireEvent.click(
|
||||||
|
within(dialog).getByRole('button', { name: '发布到广场' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<PuzzleResultView
|
||||||
|
session={createSession()}
|
||||||
|
error="光点余额不足"
|
||||||
|
isBusy={false}
|
||||||
|
onBack={() => {}}
|
||||||
|
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', () => {
|
test('generates six tags after work title and description are filled', () => {
|
||||||
const onExecuteAction = vi.fn();
|
const onExecuteAction = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -1014,6 +1014,7 @@ function PuzzleLevelDetailDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PuzzlePublishDialog({
|
function PuzzlePublishDialog({
|
||||||
|
actionError,
|
||||||
blockers,
|
blockers,
|
||||||
editState,
|
editState,
|
||||||
imageRefreshKey,
|
imageRefreshKey,
|
||||||
@@ -1022,6 +1023,7 @@ function PuzzlePublishDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onPublish,
|
onPublish,
|
||||||
}: {
|
}: {
|
||||||
|
actionError: string | null;
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
editState: DraftEditState;
|
editState: DraftEditState;
|
||||||
imageRefreshKey: string;
|
imageRefreshKey: string;
|
||||||
@@ -1076,7 +1078,11 @@ function PuzzlePublishDialog({
|
|||||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||||
发布检查
|
发布检查
|
||||||
</div>
|
</div>
|
||||||
{publishReady ? (
|
{actionError ? (
|
||||||
|
<div className="platform-banner platform-banner--danger text-sm leading-6">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
) : publishReady ? (
|
||||||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||||||
当前作品已满足发布条件。
|
当前作品已满足发布条件。
|
||||||
</div>
|
</div>
|
||||||
@@ -1361,6 +1367,7 @@ function PuzzleWorkInfoTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PuzzleResultActionBar({
|
function PuzzleResultActionBar({
|
||||||
|
actionError,
|
||||||
editState,
|
editState,
|
||||||
imageRefreshKey,
|
imageRefreshKey,
|
||||||
isBusy,
|
isBusy,
|
||||||
@@ -1368,6 +1375,7 @@ function PuzzleResultActionBar({
|
|||||||
publishBlockers,
|
publishBlockers,
|
||||||
onPublish,
|
onPublish,
|
||||||
}: {
|
}: {
|
||||||
|
actionError: string | null;
|
||||||
editState: DraftEditState;
|
editState: DraftEditState;
|
||||||
imageRefreshKey: string;
|
imageRefreshKey: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
@@ -1376,12 +1384,21 @@ function PuzzleResultActionBar({
|
|||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [showPublishDialog, setShowPublishDialog] = useState(false);
|
const [showPublishDialog, setShowPublishDialog] = useState(false);
|
||||||
|
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
|
||||||
|
|
||||||
|
const closePublishDialog = () => {
|
||||||
|
setHasAttemptedPublish(false);
|
||||||
|
setShowPublishDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPublishDialog(true)}
|
onClick={() => {
|
||||||
|
setHasAttemptedPublish(false);
|
||||||
|
setShowPublishDialog(true);
|
||||||
|
}}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
|
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -1393,13 +1410,17 @@ function PuzzleResultActionBar({
|
|||||||
|
|
||||||
{showPublishDialog ? (
|
{showPublishDialog ? (
|
||||||
<PuzzlePublishDialog
|
<PuzzlePublishDialog
|
||||||
|
actionError={hasAttemptedPublish ? actionError : null}
|
||||||
blockers={publishBlockers}
|
blockers={publishBlockers}
|
||||||
editState={editState}
|
editState={editState}
|
||||||
imageRefreshKey={imageRefreshKey}
|
imageRefreshKey={imageRefreshKey}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
publishReady={publishReady}
|
publishReady={publishReady}
|
||||||
onClose={() => setShowPublishDialog(false)}
|
onClose={closePublishDialog}
|
||||||
onPublish={onPublish}
|
onPublish={() => {
|
||||||
|
setHasAttemptedPublish(true);
|
||||||
|
onPublish();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1671,6 +1692,7 @@ export function PuzzleResultView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PuzzleResultActionBar
|
<PuzzleResultActionBar
|
||||||
|
actionError={error}
|
||||||
editState={editState}
|
editState={editState}
|
||||||
imageRefreshKey={imageRefreshKey}
|
imageRefreshKey={imageRefreshKey}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
|
|||||||
@@ -210,6 +210,12 @@ async function openExistingRpgDraft(
|
|||||||
await user.click(await screen.findByRole('button', { name: actionName }));
|
await user.click(await screen.findByRole('button', { name: actionName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
};
|
||||||
|
|
||||||
function getPlatformTabPanel(tab: string) {
|
function getPlatformTabPanel(tab: string) {
|
||||||
const panel = document.getElementById(`platform-tab-panel-${tab}`);
|
const panel = document.getElementById(`platform-tab-panel-${tab}`);
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
@@ -3054,11 +3060,7 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
|
|||||||
profileId: 'puzzle-profile-public-1',
|
profileId: 'puzzle-profile-public-1',
|
||||||
levelId: null,
|
levelId: null,
|
||||||
},
|
},
|
||||||
{
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
skipRefresh: true,
|
|
||||||
notifyAuthStateChange: false,
|
|
||||||
clearAuthOnUnauthorized: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3673,10 +3675,13 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(startPuzzleRun).toHaveBeenCalledWith({
|
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||||
profileId: 'puzzle-profile-public-1',
|
{
|
||||||
levelId: null,
|
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-0"]')!);
|
||||||
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
|
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,
|
elapsedMs: 18_000,
|
||||||
nickname: '测试玩家',
|
nickname: '测试玩家',
|
||||||
},
|
},
|
||||||
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3711,6 +3717,8 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||||
clearedFirstLevel.runId,
|
clearedFirstLevel.runId,
|
||||||
|
{},
|
||||||
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -3875,6 +3883,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
|
|||||||
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
|
||||||
clearedThirdLevel.runId,
|
clearedThirdLevel.runId,
|
||||||
{ targetProfileId: 'puzzle-profile-similar-2' },
|
{ targetProfileId: 'puzzle-profile-similar-2' },
|
||||||
|
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -310,16 +310,6 @@ const originalMatchMedia = window.matchMedia;
|
|||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
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 = {
|
const puzzlePublicEntry = {
|
||||||
sourceType: 'puzzle',
|
sourceType: 'puzzle',
|
||||||
workId: 'puzzle-work-public-1',
|
workId: 'puzzle-work-public-1',
|
||||||
@@ -534,6 +524,7 @@ function renderLoggedOutHomeView(
|
|||||||
| 'onSelectPreviousRecommendEntry'
|
| 'onSelectPreviousRecommendEntry'
|
||||||
>
|
>
|
||||||
> = {},
|
> = {},
|
||||||
|
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
|
||||||
) {
|
) {
|
||||||
return render(
|
return render(
|
||||||
<AuthUiContext.Provider
|
<AuthUiContext.Provider
|
||||||
@@ -556,7 +547,7 @@ function renderLoggedOutHomeView(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RpgEntryHomeView
|
<RpgEntryHomeView
|
||||||
activeTab="home"
|
activeTab={activeTab}
|
||||||
onTabChange={vi.fn()}
|
onTabChange={vi.fn()}
|
||||||
hasSavedGame={false}
|
hasSavedGame={false}
|
||||||
savedSnapshot={null}
|
savedSnapshot={null}
|
||||||
@@ -602,19 +593,27 @@ function renderStatefulLoggedOutHomeView(
|
|||||||
| 'latestEntries'
|
| 'latestEntries'
|
||||||
| 'onOpenGalleryDetail'
|
| 'onOpenGalleryDetail'
|
||||||
| 'onSearchPublicCode'
|
| 'onSearchPublicCode'
|
||||||
|
| 'recommendRuntimeContent'
|
||||||
|
| 'activeRecommendEntryKey'
|
||||||
|
| 'onSelectNextRecommendEntry'
|
||||||
|
| 'onSelectPreviousRecommendEntry'
|
||||||
>
|
>
|
||||||
> = {},
|
> = {},
|
||||||
) {
|
) {
|
||||||
|
const authSpies = {
|
||||||
|
openLoginModal: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
function StatefulLoggedOutHomeView() {
|
function StatefulLoggedOutHomeView() {
|
||||||
const [activeTab, setActiveTab] =
|
const [activeTab, setActiveTab] =
|
||||||
useState<RpgEntryHomeViewProps['activeTab']>('home');
|
useState<RpgEntryHomeViewProps['activeTab']>('category');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthUiContext.Provider
|
<AuthUiContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user: null,
|
user: null,
|
||||||
canAccessProtectedData: false,
|
canAccessProtectedData: false,
|
||||||
openLoginModal: vi.fn(),
|
openLoginModal: authSpies.openLoginModal,
|
||||||
requireAuth: vi.fn(),
|
requireAuth: vi.fn(),
|
||||||
openSettingsModal: vi.fn(),
|
openSettingsModal: vi.fn(),
|
||||||
openAccountModal: vi.fn(),
|
openAccountModal: vi.fn(),
|
||||||
@@ -651,7 +650,14 @@ function renderStatefulLoggedOutHomeView(
|
|||||||
onOpenCreateWorld={vi.fn()}
|
onOpenCreateWorld={vi.fn()}
|
||||||
onOpenCreateTypePicker={vi.fn()}
|
onOpenCreateTypePicker={vi.fn()}
|
||||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||||
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
recommendRuntimeContent={
|
||||||
|
overrides.recommendRuntimeContent ?? (
|
||||||
|
<div data-testid="recommend-runtime" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||||
|
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
|
||||||
|
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
|
||||||
onOpenLibraryDetail={vi.fn()}
|
onOpenLibraryDetail={vi.fn()}
|
||||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||||
/>
|
/>
|
||||||
@@ -659,7 +665,10 @@ function renderStatefulLoggedOutHomeView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(<StatefulLoggedOutHomeView />);
|
return {
|
||||||
|
...render(<StatefulLoggedOutHomeView />),
|
||||||
|
openLoginModal: authSpies.openLoginModal,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -1111,35 +1120,80 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
|||||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
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();
|
const onOpenGalleryDetail = vi.fn();
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
const { openLoginModal } = renderStatefulLoggedOutHomeView({
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
onOpenGalleryDetail,
|
onOpenGalleryDetail,
|
||||||
});
|
});
|
||||||
|
const bottomNav = document.querySelector('.platform-bottom-nav');
|
||||||
|
if (!bottomNav) {
|
||||||
|
throw new Error('缺少底部导航');
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
await user.click(
|
||||||
const runtimePanel = document.querySelector('.platform-recommend-runtime-panel');
|
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||||
expect(runtimePanel).toBeTruthy();
|
);
|
||||||
expect(runtimePanel?.className).not.toContain('bg-black');
|
await user.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
|
||||||
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
|
|
||||||
|
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(
|
expect(
|
||||||
document.querySelector('.platform-public-work-card__cover'),
|
document.querySelector('.platform-public-work-card__cover'),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(screen.getByText('拼图玩家')).toBeTruthy();
|
|
||||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
|
fireEvent.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
|
||||||
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('奇幻拼图 作品信息'));
|
|
||||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1151,38 +1205,15 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
|
|||||||
recommendRuntimeContent: null,
|
recommendRuntimeContent: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadingState = screen.getByText('加载中...');
|
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
||||||
expect(loadingState.className).toContain('platform-recommend-runtime-state');
|
|
||||||
expect(loadingState.className).not.toContain('bg-black');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend meta swipes between public works', () => {
|
test('logged out active recommend bottom tab selects next work without login', async () => {
|
||||||
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 () => {
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSelectNextRecommendEntry = vi.fn();
|
const onSelectNextRecommendEntry = vi.fn();
|
||||||
|
const openLoginModal = vi.fn();
|
||||||
|
|
||||||
renderLoggedOutHomeView(vi.fn(), {
|
renderLoggedOutHomeView(openLoginModal, {
|
||||||
latestEntries: [puzzlePublicEntry],
|
latestEntries: [puzzlePublicEntry],
|
||||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
onSelectNextRecommendEntry,
|
onSelectNextRecommendEntry,
|
||||||
@@ -1191,6 +1222,7 @@ test('active recommend bottom tab selects next work instead of navigating', asyn
|
|||||||
await user.click(screen.getByRole('button', { name: '下一个' }));
|
await user.click(screen.getByRole('button', { name: '下一个' }));
|
||||||
|
|
||||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openLoginModal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
document
|
document
|
||||||
.querySelector('.platform-recommend-work-meta__avatar img')
|
.querySelector('.platform-recommend-cover-only__author img')
|
||||||
?.getAttribute('src'),
|
?.getAttribute('src'),
|
||||||
).toBe('data:image/png;base64,AUTHOR');
|
).toBe('data:image/png;base64,AUTHOR');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={`登录后游玩 ${entry.worldName}`}
|
||||||
|
className="platform-recommend-cover-only"
|
||||||
|
>
|
||||||
|
{coverImage ? (
|
||||||
|
<ResolvedAssetBackdrop
|
||||||
|
src={coverImage}
|
||||||
|
alt={entry.worldName}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
|
||||||
|
<div className="platform-recommend-cover-only__body">
|
||||||
|
<span className="platform-public-work-card__kind">{typeLabel}</span>
|
||||||
|
<span className="platform-recommend-cover-only__title">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="platform-recommend-cover-only__author">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="platform-public-work-card__author-avatar"
|
||||||
|
>
|
||||||
|
{normalizedAuthorAvatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={normalizedAuthorAvatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="platform-public-work-card__author-avatar-image"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
authorAvatarLabel
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{authorName}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CreationLibraryCard({
|
function CreationLibraryCard({
|
||||||
entry,
|
entry,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -3049,9 +3109,9 @@ export function RpgEntryHomeView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visibleTabs.includes(activeTab)) {
|
if (!visibleTabs.includes(activeTab)) {
|
||||||
onTabChange('home');
|
onTabChange(isAuthenticated ? 'home' : 'category');
|
||||||
}
|
}
|
||||||
}, [activeTab, onTabChange, visibleTabs]);
|
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisitedTabs((currentTabs) => {
|
setVisitedTabs((currentTabs) => {
|
||||||
@@ -3705,6 +3765,18 @@ export function RpgEntryHomeView({
|
|||||||
) ??
|
) ??
|
||||||
recommendedFeedEntries[0] ??
|
recommendedFeedEntries[0] ??
|
||||||
null;
|
null;
|
||||||
|
const openActiveRecommendEntry = useCallback(() => {
|
||||||
|
if (!activeRecommendEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenGalleryDetail(activeRecommendEntry);
|
||||||
|
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]);
|
||||||
const selectNextRecommendEntry = useCallback(() => {
|
const selectNextRecommendEntry = useCallback(() => {
|
||||||
onSelectNextRecommendEntry?.();
|
onSelectNextRecommendEntry?.();
|
||||||
}, [onSelectNextRecommendEntry]);
|
}, [onSelectNextRecommendEntry]);
|
||||||
@@ -3787,6 +3859,12 @@ export function RpgEntryHomeView({
|
|||||||
<div className="platform-recommend-runtime-state">
|
<div className="platform-recommend-runtime-state">
|
||||||
正在读取公开作品...
|
正在读取公开作品...
|
||||||
</div>
|
</div>
|
||||||
|
) : !isAuthenticated && activeRecommendEntry ? (
|
||||||
|
<RecommendCoverOnlyCard
|
||||||
|
entry={activeRecommendEntry}
|
||||||
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||||
|
onClick={openActiveRecommendEntry}
|
||||||
|
/>
|
||||||
) : recommendRuntimeError ? (
|
) : recommendRuntimeError ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3808,7 +3886,7 @@ export function RpgEntryHomeView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeRecommendEntry ? (
|
{activeRecommendEntry && isAuthenticated ? (
|
||||||
<RecommendRuntimeMeta
|
<RecommendRuntimeMeta
|
||||||
entry={activeRecommendEntry}
|
entry={activeRecommendEntry}
|
||||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||||
@@ -4685,6 +4763,12 @@ export function RpgEntryHomeView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated && tab === 'home') {
|
||||||
|
onTabChange(tab);
|
||||||
|
authUi?.openLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onTabChange(tab);
|
onTabChange(tab);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -4818,7 +4902,15 @@ export function RpgEntryHomeView({
|
|||||||
label={tabLabels[tab]}
|
label={tabLabels[tab]}
|
||||||
icon={tabIcons[tab]}
|
icon={tabIcons[tab]}
|
||||||
emphasized={tab === 'create'}
|
emphasized={tab === 'create'}
|
||||||
onClick={() => onTabChange(tab)}
|
onClick={() => {
|
||||||
|
if (!isAuthenticated && tab === 'home') {
|
||||||
|
onTabChange(tab);
|
||||||
|
authUi?.openLoginModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTabChange(tab);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export function useRpgEntryBootstrap(
|
|||||||
PlatformBrowseHistoryEntry[]
|
PlatformBrowseHistoryEntry[]
|
||||||
>([]);
|
>([]);
|
||||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
||||||
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
|
const [platformTab, setPlatformTabState] =
|
||||||
|
useState<PlatformHomeTab>('category');
|
||||||
const [platformError, setPlatformError] = useState<string | null>(null);
|
const [platformError, setPlatformError] = useState<string | null>(null);
|
||||||
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
const [dashboardError, setDashboardError] = useState<string | null>(null);
|
||||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||||
@@ -329,8 +330,8 @@ export function useRpgEntryBootstrap(
|
|||||||
!hasInitialAgentSession &&
|
!hasInitialAgentSession &&
|
||||||
!hasExplicitPlatformTabSelectionRef.current
|
!hasExplicitPlatformTabSelectionRef.current
|
||||||
) {
|
) {
|
||||||
// 中文注释:saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页。
|
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口。
|
||||||
setPlatformTabState('home');
|
setPlatformTabState(isAuthenticated ? 'home' : 'category');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|||||||
@@ -2423,6 +2423,55 @@ body {
|
|||||||
background: var(--platform-recommend-runtime-fill);
|
background: var(--platform-recommend-runtime-fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-recommend-cover-only {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
background: var(--platform-recommend-runtime-fill);
|
||||||
|
color: white;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-cover-only__body {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-cover-only__title {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
font-size: clamp(1.55rem, 7.2vw, 2.35rem);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-recommend-cover-only__author {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-recommend-runtime-state {
|
.platform-recommend-runtime-state {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
|
import {
|
||||||
|
clearStoredAccessToken,
|
||||||
|
getStoredAccessToken,
|
||||||
|
setStoredAccessToken,
|
||||||
|
} from './apiClient';
|
||||||
import {
|
import {
|
||||||
clearSignedAssetReadUrlCache,
|
clearSignedAssetReadUrlCache,
|
||||||
getSignedAssetReadUrl,
|
getSignedAssetReadUrl,
|
||||||
@@ -259,4 +263,42 @@ describe('assetReadUrlService', () => {
|
|||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getSignedAssetReadUrl 401 不会清空全局登录态', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
error: {
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
message: '登录状态已失效',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
apiVersion: '2026-04-08',
|
||||||
|
routeVersion: '2026-04-08',
|
||||||
|
latencyMs: 1,
|
||||||
|
timestamp: '2099-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getSignedAssetReadUrl({
|
||||||
|
legacyPublicPath:
|
||||||
|
'/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('登录状态已失效');
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getStoredAccessToken()).toBe('test-access-token');
|
||||||
|
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ApiClientError, requestJson } from './apiClient';
|
import {
|
||||||
|
ApiClientError,
|
||||||
|
type ApiRequestOptions,
|
||||||
|
requestJson,
|
||||||
|
} from './apiClient';
|
||||||
|
|
||||||
export type AssetReadUrlRequest = {
|
export type AssetReadUrlRequest = {
|
||||||
objectKey?: string;
|
objectKey?: string;
|
||||||
@@ -41,6 +45,11 @@ type CachedReadUrlFailureEntry = {
|
|||||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||||
|
const ASSET_READ_URL_BACKGROUND_OPTIONS = {
|
||||||
|
skipRefresh: true,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
} satisfies ApiRequestOptions;
|
||||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||||
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||||
@@ -165,6 +174,10 @@ export async function getSignedAssetReadUrl(
|
|||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
'获取资源访问地址失败',
|
'获取资源访问地址失败',
|
||||||
|
{
|
||||||
|
// 中文注释:图片换签属于展示层后台请求,失败只影响当前图片,不应刷新或清空全局登录态。
|
||||||
|
...ASSET_READ_URL_BACKGROUND_OPTIONS,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const payload = resolveSignedReadPayload(response);
|
const payload = resolveSignedReadPayload(response);
|
||||||
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
|
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
|
||||||
|
|||||||
72
src/services/hyper3dModelGenerationService.ts
Normal file
72
src/services/hyper3dModelGenerationService.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type {
|
||||||
|
Hyper3dDownloadRequest,
|
||||||
|
Hyper3dDownloadResponse,
|
||||||
|
Hyper3dImageToModelRequest,
|
||||||
|
Hyper3dTaskStatusRequest,
|
||||||
|
Hyper3dTaskStatusResponse,
|
||||||
|
Hyper3dTaskSubmitResponse,
|
||||||
|
Hyper3dTextToModelRequest,
|
||||||
|
} from '../../packages/shared/src/contracts/hyper3d';
|
||||||
|
|
||||||
|
import { requestJson } from './apiClient';
|
||||||
|
|
||||||
|
const HYPER3D_API_BASE = '/api/assets/hyper3d';
|
||||||
|
const GENERATION_REQUEST_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
|
function postHyper3dJson<TResponse>(
|
||||||
|
path: string,
|
||||||
|
payload: unknown,
|
||||||
|
fallbackMessage: string,
|
||||||
|
timeoutMs = GENERATION_REQUEST_TIMEOUT_MS,
|
||||||
|
) {
|
||||||
|
return requestJson<TResponse>(
|
||||||
|
`${HYPER3D_API_BASE}${path}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
fallbackMessage,
|
||||||
|
{
|
||||||
|
timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitHyper3dTextToModel(
|
||||||
|
payload: Hyper3dTextToModelRequest,
|
||||||
|
) {
|
||||||
|
return postHyper3dJson<Hyper3dTaskSubmitResponse>(
|
||||||
|
'/text-to-model',
|
||||||
|
payload,
|
||||||
|
'提交文生 3D 模型任务失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitHyper3dImageToModel(
|
||||||
|
payload: Hyper3dImageToModelRequest,
|
||||||
|
) {
|
||||||
|
return postHyper3dJson<Hyper3dTaskSubmitResponse>(
|
||||||
|
'/image-to-model',
|
||||||
|
payload,
|
||||||
|
'提交图生 3D 模型任务失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHyper3dTaskStatus(payload: Hyper3dTaskStatusRequest) {
|
||||||
|
return postHyper3dJson<Hyper3dTaskStatusResponse>(
|
||||||
|
'/status',
|
||||||
|
payload,
|
||||||
|
'查询 3D 模型任务状态失败',
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHyper3dDownloads(payload: Hyper3dDownloadRequest) {
|
||||||
|
return postHyper3dJson<Hyper3dDownloadResponse>(
|
||||||
|
'/download',
|
||||||
|
payload,
|
||||||
|
'获取 3D 模型下载列表失败',
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ export async function dragPuzzlePieceOrGroup(
|
|||||||
export async function advancePuzzleNextLevel(
|
export async function advancePuzzleNextLevel(
|
||||||
runId: string,
|
runId: string,
|
||||||
payload: AdvancePuzzleNextLevelRequest = {},
|
payload: AdvancePuzzleNextLevelRequest = {},
|
||||||
|
options: PuzzleRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
const targetProfileId = payload.targetProfileId?.trim() ?? '';
|
||||||
return requestJson<PuzzleRunResponse>(
|
return requestJson<PuzzleRunResponse>(
|
||||||
@@ -135,6 +136,9 @@ export async function advancePuzzleNextLevel(
|
|||||||
'进入下一关失败',
|
'进入下一关失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +149,7 @@ export async function advancePuzzleNextLevel(
|
|||||||
export async function submitPuzzleLeaderboard(
|
export async function submitPuzzleLeaderboard(
|
||||||
runId: string,
|
runId: string,
|
||||||
payload: SubmitPuzzleLeaderboardRequest,
|
payload: SubmitPuzzleLeaderboardRequest,
|
||||||
|
options: PuzzleRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestJson<PuzzleRunResponse>(
|
return requestJson<PuzzleRunResponse>(
|
||||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||||
@@ -156,6 +161,9 @@ export async function submitPuzzleLeaderboard(
|
|||||||
'提交拼图排行榜失败',
|
'提交拼图排行榜失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,6 +174,7 @@ export async function submitPuzzleLeaderboard(
|
|||||||
export async function updatePuzzleRunPause(
|
export async function updatePuzzleRunPause(
|
||||||
runId: string,
|
runId: string,
|
||||||
payload: UpdatePuzzleRuntimePauseRequest,
|
payload: UpdatePuzzleRuntimePauseRequest,
|
||||||
|
options: PuzzleRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestJson<PuzzleRunResponse>(
|
return requestJson<PuzzleRunResponse>(
|
||||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/pause`,
|
||||||
@@ -177,6 +186,9 @@ export async function updatePuzzleRunPause(
|
|||||||
'更新拼图计时状态失败',
|
'更新拼图计时状态失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -187,6 +199,7 @@ export async function updatePuzzleRunPause(
|
|||||||
export async function usePuzzleRuntimeProp(
|
export async function usePuzzleRuntimeProp(
|
||||||
runId: string,
|
runId: string,
|
||||||
payload: UsePuzzleRuntimePropRequest,
|
payload: UsePuzzleRuntimePropRequest,
|
||||||
|
options: PuzzleRuntimeRequestOptions = {},
|
||||||
) {
|
) {
|
||||||
return requestJson<PuzzleRunResponse>(
|
return requestJson<PuzzleRunResponse>(
|
||||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/props`,
|
||||||
@@ -198,6 +211,9 @@ export async function usePuzzleRuntimeProp(
|
|||||||
'使用拼图道具失败',
|
'使用拼图道具失败',
|
||||||
{
|
{
|
||||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||||
|
skipRefresh: options.skipRefresh,
|
||||||
|
notifyAuthStateChange: options.notifyAuthStateChange,
|
||||||
|
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user