fix: stabilize rpg creation entry and opening cg
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ temp*build*/
|
|||||||
/.codex-temp
|
/.codex-temp
|
||||||
/target/
|
/target/
|
||||||
/logs
|
/logs
|
||||||
|
/server-rs/crates/*/logs/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.env.secrets.local
|
.env.secrets.local
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
|
|||||||
@@ -113,6 +113,14 @@
|
|||||||
- 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。
|
- 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。
|
||||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。
|
||||||
|
|
||||||
|
## 2026-05-21 RPG publish_world 设定文本以后端草稿真相派生
|
||||||
|
|
||||||
|
- 背景:RPG 结果页发布动作只保证提交 `{ action: 'publish_world' }`;旧 agent 会话可能没有 `seed_text`,但 `draft_profile_json` 已经通过 `publish_gate` 并可发布。
|
||||||
|
- 决策:发布正式世界时,`spacetime-module` 不再把 `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payload、当前草稿 profile 和 seed 依次派生。
|
||||||
|
- 影响范围:RPG / custom-world agent 发布链路、`custom_world_profile` 编译入库、公开 gallery 投影。
|
||||||
|
- 验证方式:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`;本地 api-server 重启后检查 `/healthz`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
|
## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块
|
||||||
|
|
||||||
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。
|
||||||
@@ -349,6 +357,14 @@
|
|||||||
- 验证方式:执行入口配置、创作 Hub、平台入口交互和 api-server 路由熔断定向测试,确认“视觉小说”不出现在创作页且 `/api/creation/visual-novel/*` 默认被熔断。
|
- 验证方式:执行入口配置、创作 Hub、平台入口交互和 api-server 路由熔断定向测试,确认“视觉小说”不出现在创作页且 `/api/creation/visual-novel/*` 默认被熔断。
|
||||||
- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`、`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。
|
- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`、`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。
|
||||||
|
|
||||||
|
## 2026-05-20 RPG 创作入口开放
|
||||||
|
|
||||||
|
- 背景:RPG 文字冒险能力已经具备历史 custom-world 创作和运行闭环,但入口默认种子仍 `visible=false`,创作页不展示。
|
||||||
|
- 决策:SpacetimeDB `creation_entry_type_config` 默认种子中 `rpg.visible=true` 且 `open=true`,旧默认隐藏配置只在标题、subtitle、badge、图片、排序和开关完全匹配时迁移为可见可创建。`airp` 仍保持 AI RPG 占位,不接管当前 RPG 链路。结构化创作 / RPG JSON 链路默认关闭 Responses `web_search`,需要联网增强时才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true` 显式启用;未开通工具的上游会返回 `ToolNotOpen`,不能把这类失败暴露成“模型返回结果解析失败”。
|
||||||
|
- 影响范围:创作入口默认种子、旧库入口纠偏、`api-server` 入口熔断、创作页模板 Tab、创作 Hub 测试、玩法链路文档和后端路由文档。
|
||||||
|
- 验证方式:执行入口配置、api-server 路由熔断、创作 Hub 和平台入口交互定向测试,确认“文字冒险”出现在创作入口,`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*` 都按 `rpg` 入口开关熔断。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-05-10 运行态输入设备抽象层全项目通用化
|
## 2026-05-10 运行态输入设备抽象层全项目通用化
|
||||||
|
|
||||||
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
|
- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
|
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
|
||||||
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
|
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
|
||||||
|
|
||||||
|
## RPG 发布不能只依赖 agent session seed_text
|
||||||
|
|
||||||
|
- 现象:RPG 结果页 `publish_world` 返回 `UPSTREAM_ERROR`,details 为 `custom_world.setting_text 不能为空`;同一 session 的 `result-view` 日志显示 `publish_ready=true`。
|
||||||
|
- 原因:前端发布动作只提交 `{ action: 'publish_world' }`,旧 agent 会话的 `seed_text` 可能为空;如果后端只从 action payload 或 `seed_text` 取 `setting_text`,就会在最终 compile / publish 校验阶段失败。
|
||||||
|
- 处理:`module-custom-world::resolve_custom_world_publish_setting_text(...)` 以当前 `draft_profile_json` 为草稿真相,优先读取 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title`,最后才回退 `seed_text`。
|
||||||
|
- 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。
|
||||||
|
- 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## Windows provision 下载截断要断点续传而不是回退目标机下载
|
## Windows provision 下载截断要断点续传而不是回退目标机下载
|
||||||
|
|
||||||
- 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
|
- 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。
|
||||||
@@ -374,6 +382,22 @@
|
|||||||
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`。
|
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run dev:api-server` 后检查 `/healthz`。
|
||||||
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts`、`server-rs/crates/api-server/src/puzzle.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。
|
- 关联:`src/services/creation-agent/creationAgentClientFactory.ts`、`server-rs/crates/api-server/src/puzzle.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。
|
||||||
|
|
||||||
|
## 开局 CG 故事板生图失败先查 VectorEngine 请求预算和旧进程
|
||||||
|
|
||||||
|
- 现象:RPG 结果页点击开局 CG 后,`POST /api/runtime/custom-world/opening-cg` 在较长等待后返回“开局 CG 故事板生成失败:创建图片生成任务失败:error sending request for url (https://api.vectorengine.ai/v1/images/generations)”。
|
||||||
|
- 原因:该故事板会把角色图和首幕背景图作为参考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生成耗时都比普通单图更大;若运行中的 `api-server` 仍沿用旧 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者参考图过大,会在请求发送/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP 响应,通常优先怀疑大 JSON 请求体、上游网关中断或 HTTP 协议兼容,而不是业务响应解析失败。直接请求 VectorEngine 若无效 token 可快速返回 401,不能据此判断真实生图不会超时。
|
||||||
|
- 处理:开局 CG 参考图入参先压到单边 768 的 JPEG;`/v1/images/generations` 保持 reqwest 默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。后端图片 helper 将 `request_body_bytes`、每张参考图 Data URL 长度、`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`,前端优先展示 `details.reason`。修改 `.env.secrets.local` 后必须重启 `api-server`,`npm run dev` 终端用 `rs api-server`,否则旧进程仍按旧超时运行。
|
||||||
|
- 验证:分别运行 `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 和 `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实联调重启后再触发开局 CG,若仍失败看返回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint` 和 `logs/api-server/` 同一 request_id。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/custom_world_ai.rs`、`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 开局 CG 成功后又变空白要保留 profile.openingCg
|
||||||
|
|
||||||
|
- 现象:RPG 结果页里的开局 CG 成功显示一瞬后,窗口又退回空白占位。
|
||||||
|
- 原因:`openingCg` 只存在于结果页 profile 槽位,如果父层在 `onProfileChange` 后重新同步了 profile,却经过 `normalizeCustomWorldProfileRecord` 或作品库写回时丢掉 `openingCg`,预览就会从视频 / 故事板回退为空白。
|
||||||
|
- 处理:`src/data/customWorldLibrary.ts` 的 profile 归一化必须透传 `openingCg`;结果页和父层后续同步都应把它当作受控资产槽位,而不是临时 UI 状态。
|
||||||
|
- 验证:`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生成后即使父层做一次归一化回写,开局 CG 仍继续显示。
|
||||||
|
- 关联:`src/data/customWorldLibrary.ts`、`src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`、`src/components/CustomWorldEntityCatalog.tsx`。
|
||||||
|
|
||||||
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
|
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
|
||||||
|
|
||||||
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError`、`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
|
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError`、`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`CustomWorldAgentSession`
|
- Rust 结构体:`CustomWorldAgentSession`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
- 发布约束:`publish_world` 的 action payload 不要求携带 `settingText`;`spacetime-module` 调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)`,优先从当前 `draft_profile_json` 草稿真相派生正式 `setting_text`,避免旧会话 `seed_text` 为空时在最终 compile / publish 阶段触发 `custom_world.setting_text 不能为空`。
|
||||||
|
|
||||||
### `custom_world_draft_card`
|
### `custom_world_draft_card`
|
||||||
|
|
||||||
@@ -597,6 +598,10 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||||
|
|
||||||
|
RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`;历史 `custom-world` 路由仍是 RPG 的工程域与运行态源类型。入口熔断把 `/api/runtime/custom-world*`、`/api/story/*` 和 `/api/runtime/chat/*` 统一映射到 `rpg`,不要新增平行 `airp` 路由或用 `airp` 接管当前文字冒险链路。
|
||||||
|
|
||||||
|
结构化创作和 RPG 的 LLM JSON 链路默认不启用 Responses `web_search`;只有在明确需要联网增强时,才通过 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` 显式打开。否则未开通工具的上游会先吐自然语言再返回 `ToolNotOpen`,这类失败要按上游工具不可用处理,不要误判成模型返回结果解析失败。
|
||||||
|
|
||||||
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。
|
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。
|
||||||
|
|
||||||
### `quest_log`
|
### `quest_log`
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ npm run dev:api-server
|
|||||||
|
|
||||||
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||||
|
|
||||||
|
本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
|
||||||
|
|
||||||
查看本地 Rust / SpacetimeDB 日志:
|
查看本地 Rust / SpacetimeDB 日志:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -213,6 +215,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
|||||||
- `WECHAT_*`
|
- `WECHAT_*`
|
||||||
- `ALIYUN_OSS_*`
|
- `ALIYUN_OSS_*`
|
||||||
|
|
||||||
|
结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。
|
||||||
|
|
||||||
### 手机验证码短信
|
### 手机验证码短信
|
||||||
|
|
||||||
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
||||||
|
|||||||
@@ -34,6 +34,24 @@
|
|||||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
|
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
|
||||||
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||||
|
|
||||||
|
## RPG / 自定义世界
|
||||||
|
|
||||||
|
当前 RPG 创作入口使用 `playId = rpg`,工程域和运行态源类型沿用历史 `custom-world`。默认入口状态为 `visible=true`、`open=true`,对外展示为“文字冒险”;`airp` 仍是独立的“AI RPG”占位入口,保持 `open=false`,不要把它当作当前 RPG 创作链路开放。
|
||||||
|
|
||||||
|
当前链路为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创作入口 -> RPG Agent 共创工作台 -> 生成过程页 -> 结果页 -> 进入世界/试玩 -> 发布 -> RPG 运行态
|
||||||
|
```
|
||||||
|
|
||||||
|
RPG 是历史既有链路例外:当前仍使用对话式 Agent 共创工作台和 RPG 资产编辑器体系,不作为新增玩法默认模板复制。新增玩法继续遵循本文默认的表单/图片输入工作台、`CreativeImageInputPanel` 单图槽位和通用系列素材图集生成流程;如果要把 RPG 逐步迁回默认模式,应先补 PRD 和迁移方案,再改代码。
|
||||||
|
|
||||||
|
RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。
|
||||||
|
|
||||||
|
RPG Agent 结果页发布动作的前端契约只保证提交 `{ action: 'publish_world' }`;后端发布时以当前 `custom_world_agent_session.draft_profile_json` 为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。
|
||||||
|
|
||||||
|
RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。
|
||||||
|
|
||||||
## 拼图
|
## 拼图
|
||||||
|
|
||||||
当前拼图链路:
|
当前拼图链路:
|
||||||
|
|||||||
@@ -619,6 +619,31 @@ mod tests {
|
|||||||
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
|
assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabled_rpg_route_returns_service_unavailable() {
|
||||||
|
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||||
|
state.set_test_creation_entry_route_enabled("rpg", false);
|
||||||
|
let app = build_router(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/runtime/custom-world/agent/sessions")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let body = read_json_response(response).await;
|
||||||
|
assert_eq!(
|
||||||
|
body["error"]["details"]["reason"],
|
||||||
|
"creation_entry_disabled"
|
||||||
|
);
|
||||||
|
assert_eq!(body["error"]["details"]["creationTypeId"], "rpg");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn healthz_returns_standard_envelope_when_requested() {
|
async fn healthz_returns_standard_envelope_when_requested() {
|
||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||||
|
|||||||
@@ -260,8 +260,11 @@ impl Default for AppConfig {
|
|||||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||||
rpg_llm_web_search_enabled: true,
|
// 中文注释:创作/RPG 的结构化 JSON 链路默认不启用 Responses web_search。
|
||||||
creation_agent_llm_web_search_enabled: true,
|
// 未开通工具的上游会先吐自然语言再返回 ToolNotOpen,容易污染严格 JSON 结果;
|
||||||
|
// 需要联网增强时由部署环境显式打开对应开关。
|
||||||
|
rpg_llm_web_search_enabled: false,
|
||||||
|
creation_agent_llm_web_search_enabled: false,
|
||||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||||
dashscope_api_key: None,
|
dashscope_api_key: None,
|
||||||
dashscope_scene_image_model: String::new(),
|
dashscope_scene_image_model: String::new(),
|
||||||
@@ -1467,6 +1470,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_keeps_structured_llm_web_search_disabled() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
|
||||||
|
assert!(!config.rpg_llm_web_search_enabled);
|
||||||
|
assert!(!config.creation_agent_llm_web_search_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_reads_rpg_llm_web_search_switch() {
|
fn from_env_reads_rpg_llm_web_search_switch() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
@@ -1476,11 +1487,11 @@ mod tests {
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
|
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = AppConfig::from_env();
|
let config = AppConfig::from_env();
|
||||||
assert!(!config.rpg_llm_web_search_enabled);
|
assert!(config.rpg_llm_web_search_enabled);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||||
@@ -1496,11 +1507,11 @@ mod tests {
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||||
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
|
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = AppConfig::from_env();
|
let config = AppConfig::from_env();
|
||||||
assert!(!config.creation_agent_llm_web_search_enabled);
|
assert!(config.creation_agent_llm_web_search_enabled);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
|
||||||
|
|||||||
@@ -93,15 +93,74 @@ where
|
|||||||
F: FnMut(&str),
|
F: FnMut(&str),
|
||||||
{
|
{
|
||||||
let mut latest_reply_text = String::new();
|
let mut latest_reply_text = String::new();
|
||||||
|
let turn_output = match request_stream_creation_agent_json_turn_once(
|
||||||
|
llm_client,
|
||||||
|
system_prompt.clone(),
|
||||||
|
user_prompt.clone(),
|
||||||
|
enable_web_search,
|
||||||
|
on_reply_update,
|
||||||
|
&mut latest_reply_text,
|
||||||
|
!enable_web_search,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(turn_output) => turn_output,
|
||||||
|
Err(CreationAgentJsonTurnFailure::Stream(error))
|
||||||
|
if enable_web_search && is_web_search_tool_unavailable(&error) =>
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
error = %error,
|
||||||
|
"创作 Agent 流式联网搜索插件不可用,自动降级为无联网搜索重试"
|
||||||
|
);
|
||||||
|
latest_reply_text.clear();
|
||||||
|
request_stream_creation_agent_json_turn_once(
|
||||||
|
llm_client,
|
||||||
|
system_prompt,
|
||||||
|
user_prompt,
|
||||||
|
false,
|
||||||
|
on_reply_update,
|
||||||
|
&mut latest_reply_text,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reply_text = read_reply_text(&turn_output.parsed);
|
||||||
|
if let Some(reply_text) = reply_text.as_deref()
|
||||||
|
&& reply_text != latest_reply_text
|
||||||
|
{
|
||||||
|
on_reply_update(reply_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(turn_output)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_stream_creation_agent_json_turn_once<F>(
|
||||||
|
llm_client: &LlmClient,
|
||||||
|
system_prompt: String,
|
||||||
|
user_prompt: String,
|
||||||
|
enable_web_search: bool,
|
||||||
|
on_reply_update: &mut F,
|
||||||
|
latest_reply_text: &mut String,
|
||||||
|
emit_reply_updates: bool,
|
||||||
|
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
let response = llm_client
|
let response = llm_client
|
||||||
.stream_text(
|
.stream_text(
|
||||||
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|
||||||
|delta: &LlmStreamDelta| {
|
|delta: &LlmStreamDelta| {
|
||||||
|
if !emit_reply_updates {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(reply_progress) =
|
if let Some(reply_progress) =
|
||||||
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
|
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
|
||||||
&& reply_progress != latest_reply_text
|
&& reply_progress != *latest_reply_text
|
||||||
{
|
{
|
||||||
latest_reply_text = reply_progress.clone();
|
*latest_reply_text = reply_progress.clone();
|
||||||
on_reply_update(reply_progress.as_str());
|
on_reply_update(reply_progress.as_str());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,12 +169,6 @@ where
|
|||||||
.map_err(CreationAgentJsonTurnFailure::Stream)?;
|
.map_err(CreationAgentJsonTurnFailure::Stream)?;
|
||||||
let parsed = parse_json_response_text(response.content.as_str())
|
let parsed = parse_json_response_text(response.content.as_str())
|
||||||
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
|
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
|
||||||
let reply_text = read_reply_text(&parsed);
|
|
||||||
if let Some(reply_text) = reply_text.as_deref()
|
|
||||||
&& reply_text != latest_reply_text
|
|
||||||
{
|
|
||||||
on_reply_update(reply_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CreationAgentJsonTurnOutput { parsed })
|
Ok(CreationAgentJsonTurnOutput { parsed })
|
||||||
}
|
}
|
||||||
@@ -327,6 +380,7 @@ mod tests {
|
|||||||
let server = spawn_capturing_mock_server(vec![
|
let server = spawn_capturing_mock_server(vec![
|
||||||
MockResponse {
|
MockResponse {
|
||||||
body: concat!(
|
body: concat!(
|
||||||
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需要先搜索玩具王国资料。\"}\n\n",
|
||||||
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
|
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
|
||||||
"data: [DONE]\n\n"
|
"data: [DONE]\n\n"
|
||||||
)
|
)
|
||||||
@@ -391,6 +445,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() {
|
||||||
|
let server = spawn_capturing_mock_server(vec![MockResponse {
|
||||||
|
body: concat!(
|
||||||
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n",
|
||||||
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定住。\\\",\\\"progressPercent\\\":12}\"}\n\n",
|
||||||
|
"data: {\"type\":\"response.completed\"}\n\n",
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
|
}]);
|
||||||
|
let config = LlmConfig::new(
|
||||||
|
LlmProvider::Ark,
|
||||||
|
server.base_url,
|
||||||
|
"test-key".to_string(),
|
||||||
|
"test-model".to_string(),
|
||||||
|
30_000,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("LLM config should build");
|
||||||
|
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
|
||||||
|
let mut visible_replies = Vec::new();
|
||||||
|
|
||||||
|
let output = stream_creation_agent_json_turn(
|
||||||
|
Some(&llm_client),
|
||||||
|
"系统提示".to_string(),
|
||||||
|
"用户提示",
|
||||||
|
false,
|
||||||
|
CreationAgentLlmTurnErrorMessages {
|
||||||
|
model_unavailable: "模型不可用",
|
||||||
|
generation_failed: "生成失败",
|
||||||
|
parse_failed: "解析失败",
|
||||||
|
},
|
||||||
|
|text| visible_replies.push(text.to_string()),
|
||||||
|
|message| message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stream without web search should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
output.parsed["replyText"].as_str(),
|
||||||
|
Some("我先把玩具王国定住。")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visible_replies,
|
||||||
|
vec!["我先".to_string(), "我先把玩具王国定住。".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
struct MockResponse {
|
struct MockResponse {
|
||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
|||||||
if normalized.starts_with("/api/runtime/big-fish") {
|
if normalized.starts_with("/api/runtime/big-fish") {
|
||||||
return Some("big-fish");
|
return Some("big-fish");
|
||||||
}
|
}
|
||||||
|
if normalized.starts_with("/api/runtime/custom-world")
|
||||||
|
|| normalized.starts_with("/api/runtime/custom-world-library")
|
||||||
|
|| normalized.starts_with("/api/runtime/custom-world-gallery")
|
||||||
|
|| normalized.starts_with("/api/runtime/chat")
|
||||||
|
|| normalized.starts_with("/api/story")
|
||||||
|
{
|
||||||
|
return Some("rpg");
|
||||||
|
}
|
||||||
if normalized.starts_with("/api/runtime/visual-novel") {
|
if normalized.starts_with("/api/runtime/visual-novel") {
|
||||||
return Some("visual-novel");
|
return Some("visual-novel");
|
||||||
}
|
}
|
||||||
@@ -161,6 +169,26 @@ mod tests {
|
|||||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||||
Some("visual-novel"),
|
Some("visual-novel"),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"),
|
||||||
|
Some("rpg"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"),
|
||||||
|
Some("rpg"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"),
|
||||||
|
Some("rpg"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/story/sessions/runtime"),
|
||||||
|
Some("rpg"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"),
|
||||||
|
Some("rpg"),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||||
Some("bark-battle"),
|
Some("bark-battle"),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use image::{DynamicImage, GenericImageView, imageops::FilterType};
|
use image::{
|
||||||
|
DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType,
|
||||||
|
};
|
||||||
use module_assets::{
|
use module_assets::{
|
||||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||||
@@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
|
|||||||
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
|
||||||
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
|
||||||
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
|
||||||
|
const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768;
|
||||||
|
const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82;
|
||||||
|
|
||||||
struct CoverPromptContext {
|
struct CoverPromptContext {
|
||||||
opening_act_title: String,
|
opening_act_title: String,
|
||||||
@@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg(
|
|||||||
"openingSceneImageSrc",
|
"openingSceneImageSrc",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let player_role_reference = resize_image_reference_data_url(
|
||||||
|
player_role_reference,
|
||||||
|
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||||
|
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||||
|
)?;
|
||||||
|
let opening_scene_reference = resize_image_reference_data_url(
|
||||||
|
opening_scene_reference,
|
||||||
|
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||||
|
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||||
|
)?;
|
||||||
let storyboard = generate_opening_cg_storyboard(
|
let storyboard = generate_opening_cg_storyboard(
|
||||||
&state,
|
&state,
|
||||||
&owner_user_id,
|
&owner_user_id,
|
||||||
@@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resize_image_reference_data_url(
|
||||||
|
data_url: String,
|
||||||
|
max_edge: u32,
|
||||||
|
jpeg_quality: u8,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
if max_edge == 0 {
|
||||||
|
return Ok(data_url);
|
||||||
|
}
|
||||||
|
let Some(parsed) = parse_image_data_url(data_url.as_str()) else {
|
||||||
|
return Ok(data_url);
|
||||||
|
};
|
||||||
|
let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "custom-world-ai",
|
||||||
|
"message": format!("无法解析参考图:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let already_within_budget = width <= max_edge && height <= max_edge;
|
||||||
|
if already_within_budget && parsed.mime_type == "image/jpeg" {
|
||||||
|
return Ok(data_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文注释:开局 CG 故事板会同时带角色和场景两张参考图;先压到较小 JPEG,避免大图 PNG Data URL 让 VectorEngine 网关在请求发送阶段中断。
|
||||||
|
let resized = if already_within_budget {
|
||||||
|
image
|
||||||
|
} else {
|
||||||
|
image.resize(max_edge, max_edge, FilterType::Triangle)
|
||||||
|
};
|
||||||
|
let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8());
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
JpegEncoder::new_with_quality(&mut encoded, jpeg_quality)
|
||||||
|
.encode_image(&encoded_image)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "custom-world-ai",
|
||||||
|
"message": format!("压缩参考图失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"data:image/jpeg;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(encoded)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_text_to_image_generation(
|
async fn create_text_to_image_generation(
|
||||||
http_client: &reqwest::Client,
|
http_client: &reqwest::Client,
|
||||||
settings: &DashScopeSettings,
|
settings: &DashScopeSettings,
|
||||||
@@ -3065,6 +3125,34 @@ mod tests {
|
|||||||
assert_eq!(parsed.bytes, b"hello".to_vec());
|
assert_eq!(parsed.bytes, b"hello".to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_cg_reference_data_url_is_resized_to_request_budget() {
|
||||||
|
let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152));
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.expect("test image should encode");
|
||||||
|
let data_url = format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
BASE64_STANDARD.encode(cursor.into_inner())
|
||||||
|
);
|
||||||
|
|
||||||
|
let resized = resize_image_reference_data_url(
|
||||||
|
data_url,
|
||||||
|
OPENING_CG_REFERENCE_MAX_EDGE,
|
||||||
|
OPENING_CG_REFERENCE_JPEG_QUALITY,
|
||||||
|
)
|
||||||
|
.expect("reference should resize");
|
||||||
|
let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse");
|
||||||
|
let resized_image =
|
||||||
|
image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode");
|
||||||
|
let (width, height) = resized_image.dimensions();
|
||||||
|
|
||||||
|
assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||||
|
assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE);
|
||||||
|
assert_eq!(parsed.mime_type, "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn push_cover_reference_source_keeps_full_data_url() {
|
fn push_cover_reference_source_keeps_full_data_url() {
|
||||||
let mut sources = Vec::new();
|
let mut sources = Vec::new();
|
||||||
|
|||||||
@@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
|
|||||||
const MATCH3D_ITEM_SIZE_LARGE: &str = "大";
|
const MATCH3D_ITEM_SIZE_LARGE: &str = "大";
|
||||||
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中";
|
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中";
|
||||||
const MATCH3D_ITEM_SIZE_SMALL: &str = "小";
|
const MATCH3D_ITEM_SIZE_SMALL: &str = "小";
|
||||||
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!(
|
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] =
|
||||||
"../../../../public/match3d-background-references/pot-fused-reference.png"
|
include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png");
|
||||||
);
|
|
||||||
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
|
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
|
||||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||||
use crate::generated_asset_sheets::{
|
use crate::generated_asset_sheets::{
|
||||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
|
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
|
||||||
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
|
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
|
||||||
slice_generated_asset_sheet,
|
slice_generated_asset_sheet,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
|
||||||
|
|
||||||
pub(super) async fn generate_match3d_item_assets(
|
pub(super) async fn generate_match3d_item_assets(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
|
|||||||
@@ -1164,7 +1164,9 @@ fn match3d_container_reference_image_is_embedded_for_api_only_deploy() {
|
|||||||
assert_eq!(reference.mime_type, "image/png");
|
assert_eq!(reference.mime_type, "image/png");
|
||||||
assert_eq!(reference.file_name, "match3d-container-reference.png");
|
assert_eq!(reference.file_name, "match3d-container-reference.png");
|
||||||
assert!(
|
assert!(
|
||||||
reference.bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
reference
|
||||||
|
.bytes
|
||||||
|
.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
"container reference image should be PNG bytes"
|
"container reference image should be PNG bytes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::time::Duration;
|
use std::{error::Error as _, 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};
|
||||||
@@ -82,8 +82,6 @@ pub(crate) fn build_openai_image_http_client(
|
|||||||
) -> Result<reqwest::Client, AppError> {
|
) -> Result<reqwest::Client, AppError> {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||||
// 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。
|
|
||||||
.http1_only()
|
|
||||||
.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!({
|
||||||
@@ -110,21 +108,46 @@ pub(crate) async fn create_openai_image_generation(
|
|||||||
candidate_count,
|
candidate_count,
|
||||||
reference_images,
|
reference_images,
|
||||||
);
|
);
|
||||||
|
let request_body_bytes = serde_json::to_vec(&request_body).map_err(|error| {
|
||||||
|
map_openai_image_request_error(format!(
|
||||||
|
"{failure_context}:序列化图片生成请求失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let normalized_size = request_body
|
||||||
|
.get("size")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(size);
|
||||||
|
let reference_lengths = summarize_reference_data_url_lengths(reference_images);
|
||||||
|
let request_url = vector_engine_images_generation_url(settings);
|
||||||
|
tracing::info!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
endpoint = %request_url,
|
||||||
|
model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL,
|
||||||
|
prompt_chars = prompt.chars().count(),
|
||||||
|
size = normalized_size,
|
||||||
|
candidate_count = candidate_count.clamp(1, 4),
|
||||||
|
reference_count = reference_images.len(),
|
||||||
|
reference_data_url_bytes = %reference_lengths,
|
||||||
|
request_body_bytes = request_body_bytes.len(),
|
||||||
|
"VectorEngine 图片生成请求已准备"
|
||||||
|
);
|
||||||
let response = http_client
|
let response = http_client
|
||||||
.post(vector_engine_images_generation_url(settings))
|
.post(request_url.as_str())
|
||||||
.header(
|
.header(
|
||||||
header::AUTHORIZATION,
|
header::AUTHORIZATION,
|
||||||
format!("Bearer {}", settings.api_key),
|
format!("Bearer {}", settings.api_key),
|
||||||
)
|
)
|
||||||
.header(header::ACCEPT, "application/json")
|
.header(header::ACCEPT, "application/json")
|
||||||
.header(header::CONTENT_TYPE, "application/json")
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
.json(&request_body)
|
.body(request_body_bytes)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
map_openai_image_request_error(format!(
|
map_openai_image_reqwest_error(
|
||||||
"{failure_context}:创建图片生成任务失败:{error}"
|
format!("{failure_context}:创建图片生成任务失败").as_str(),
|
||||||
))
|
request_url.as_str(),
|
||||||
|
error,
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let response_status = response.status();
|
let response_status = response.status();
|
||||||
let response_text = response.text().await.map_err(|error| {
|
let response_text = response.text().await.map_err(|error| {
|
||||||
@@ -191,8 +214,11 @@ pub(crate) async fn create_openai_image_edit(
|
|||||||
)
|
)
|
||||||
.text("n", "1")
|
.text("n", "1")
|
||||||
.text("size", normalize_image_size(size));
|
.text("size", normalize_image_size(size));
|
||||||
let response = http_client
|
let request_url = vector_engine_images_edit_url(settings);
|
||||||
.post(vector_engine_images_edit_url(settings).as_str())
|
// 中文注释:只对 multipart `/v1/images/edits` 单独强制 HTTP/1.1;大 JSON generations 保持默认协商,避免 HTTP/1.1 网关在发送大请求体时中断连接。
|
||||||
|
let edit_http_client = build_openai_image_edit_http_client(settings)?;
|
||||||
|
let response = edit_http_client
|
||||||
|
.post(request_url.as_str())
|
||||||
.header(
|
.header(
|
||||||
header::AUTHORIZATION,
|
header::AUTHORIZATION,
|
||||||
format!("Bearer {}", settings.api_key),
|
format!("Bearer {}", settings.api_key),
|
||||||
@@ -202,9 +228,11 @@ pub(crate) async fn create_openai_image_edit(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
map_openai_image_request_error(format!(
|
map_openai_image_reqwest_error(
|
||||||
"{failure_context}:创建图片编辑任务失败:{error}"
|
format!("{failure_context}:创建图片编辑任务失败").as_str(),
|
||||||
))
|
request_url.as_str(),
|
||||||
|
error,
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let response_status = response.status();
|
let response_status = response.status();
|
||||||
let response_text = response.text().await.map_err(|error| {
|
let response_text = response.text().await.map_err(|error| {
|
||||||
@@ -242,6 +270,21 @@ pub(crate) async fn create_openai_image_edit(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_openai_image_edit_http_client(
|
||||||
|
settings: &OpenAiImageSettings,
|
||||||
|
) -> Result<reqwest::Client, AppError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||||
|
.http1_only()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
|
"message": format!("构造 VectorEngine 图片编辑 HTTP 客户端失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_openai_image_request_body(
|
pub(crate) fn build_openai_image_request_body(
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
negative_prompt: Option<&str>,
|
negative_prompt: Option<&str>,
|
||||||
@@ -402,6 +445,130 @@ fn map_openai_image_request_error(message: String) -> AppError {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_openai_image_reqwest_error(
|
||||||
|
context: &str,
|
||||||
|
request_url: &str,
|
||||||
|
error: reqwest::Error,
|
||||||
|
) -> AppError {
|
||||||
|
let message = format!(
|
||||||
|
"{context}:{}",
|
||||||
|
normalize_openai_reqwest_error_message(&error)
|
||||||
|
);
|
||||||
|
let source_chain = reqwest_error_source_chain(&error);
|
||||||
|
let root_source = source_chain.last().cloned().unwrap_or_default();
|
||||||
|
let source_chain_text = source_chain.join(" | ");
|
||||||
|
let is_timeout = error.is_timeout()
|
||||||
|
|| is_openai_image_timeout_message(message.as_str())
|
||||||
|
|| is_openai_image_timeout_message(source_chain_text.as_str());
|
||||||
|
let is_connect = error.is_connect();
|
||||||
|
let status = if is_timeout {
|
||||||
|
StatusCode::GATEWAY_TIMEOUT
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_GATEWAY
|
||||||
|
};
|
||||||
|
let source = source_chain.first().cloned().unwrap_or_default();
|
||||||
|
let reason = resolve_openai_image_request_failure_reason(&error, source_chain.as_slice());
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
provider = VECTOR_ENGINE_PROVIDER,
|
||||||
|
endpoint = %request_url,
|
||||||
|
timeout = is_timeout,
|
||||||
|
connect = is_connect,
|
||||||
|
request = error.is_request(),
|
||||||
|
body = error.is_body(),
|
||||||
|
source = %source,
|
||||||
|
root_source = %root_source,
|
||||||
|
source_chain = ?source_chain,
|
||||||
|
message = %message,
|
||||||
|
"VectorEngine 图片请求发送失败"
|
||||||
|
);
|
||||||
|
|
||||||
|
AppError::from_status(status).with_details(json!({
|
||||||
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
|
"message": message,
|
||||||
|
"reason": reason,
|
||||||
|
"endpoint": request_url,
|
||||||
|
"timeout": is_timeout,
|
||||||
|
"connect": is_connect,
|
||||||
|
"request": error.is_request(),
|
||||||
|
"body": error.is_body(),
|
||||||
|
"source": source,
|
||||||
|
"rootSource": root_source,
|
||||||
|
"sourceChain": source_chain,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_openai_reqwest_error_message(error: &reqwest::Error) -> String {
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reqwest_error_source_chain(error: &reqwest::Error) -> Vec<String> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut current = error.source();
|
||||||
|
while let Some(source) = current {
|
||||||
|
chain.push(source.to_string());
|
||||||
|
current = source.source();
|
||||||
|
if chain.len() >= 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_openai_image_request_failure_reason(
|
||||||
|
error: &reqwest::Error,
|
||||||
|
source_chain: &[String],
|
||||||
|
) -> &'static str {
|
||||||
|
let combined = std::iter::once(error.to_string())
|
||||||
|
.chain(source_chain.iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ");
|
||||||
|
if error.is_timeout() || is_openai_image_timeout_message(combined.as_str()) {
|
||||||
|
return "VectorEngine 图片生成请求超时,请稍后重试;如果多次复现,检查上游耗时并调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS";
|
||||||
|
}
|
||||||
|
if error.is_connect() {
|
||||||
|
return "无法连接 VectorEngine 图片生成接口,请检查服务器网络、DNS、防火墙或代理配置";
|
||||||
|
}
|
||||||
|
if error.is_body() {
|
||||||
|
return "发送 VectorEngine 图片生成请求体失败,请重试并检查参考图大小";
|
||||||
|
}
|
||||||
|
if is_openai_image_send_request_interrupted(combined.as_str()) {
|
||||||
|
return "VectorEngine 在接收图片生成请求时中断连接;请重试,若持续复现优先检查参考图体积、上游网关和 HTTP 协议兼容";
|
||||||
|
}
|
||||||
|
"VectorEngine 图片生成请求发送失败,请查看 source 字段中的底层网络错误"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_openai_image_send_request_interrupted(message: &str) -> bool {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
lower.contains("sendrequest")
|
||||||
|
|| lower.contains("connection closed")
|
||||||
|
|| lower.contains("connection reset")
|
||||||
|
|| lower.contains("broken pipe")
|
||||||
|
|| lower.contains("unexpected eof")
|
||||||
|
|| lower.contains("stream error")
|
||||||
|
|| lower.contains("body write aborted")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_openai_image_timeout_message(message: &str) -> bool {
|
||||||
|
let lower = message.to_ascii_lowercase();
|
||||||
|
lower.contains("timed out")
|
||||||
|
|| lower.contains("timeout")
|
||||||
|
|| lower.contains("operation timed out")
|
||||||
|
|| lower.contains("deadline has elapsed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_reference_data_url_lengths(reference_images: &[String]) -> String {
|
||||||
|
reference_images
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.len().to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
fn map_openai_image_upstream_error(
|
fn map_openai_image_upstream_error(
|
||||||
upstream_status: u16,
|
upstream_status: u16,
|
||||||
raw_text: &str,
|
raw_text: &str,
|
||||||
@@ -646,6 +813,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vector_engine_reqwest_error_exposes_actionable_reason() {
|
||||||
|
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||||
|
Ok(_) => panic!("invalid url should fail request build"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let app_error = map_openai_image_reqwest_error(
|
||||||
|
"开局 CG 故事板生成失败:创建图片生成任务失败",
|
||||||
|
"https://api.vectorengine.ai/v1/images/generations",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(app_error.status_code(), StatusCode::BAD_GATEWAY);
|
||||||
|
assert!(
|
||||||
|
app_error
|
||||||
|
.body_text()
|
||||||
|
.contains("开局 CG 故事板生成失败:创建图片生成任务失败")
|
||||||
|
);
|
||||||
|
assert!(format!("{app_error:?}").contains("sourceChain"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn b64_json_response_decodes_png_image() {
|
fn b64_json_response_decodes_png_image() {
|
||||||
let images = images_from_base64(
|
let images = images_from_base64(
|
||||||
|
|||||||
@@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_custom_world_publish_setting_text(
|
||||||
|
payload: &Map<String, Value>,
|
||||||
|
draft_profile: &Map<String, Value>,
|
||||||
|
seed_text: &str,
|
||||||
|
) -> String {
|
||||||
|
// 中文注释:发布按钮的前端契约只保证提交动作名;正式 settingText 必须从草稿真相补齐,
|
||||||
|
// 避免旧会话 seed_text 为空时通过 publish gate,却在最终 compile/publish 阶段失败。
|
||||||
|
read_nested_text_field(payload, &["settingText"])
|
||||||
|
.or_else(|| {
|
||||||
|
read_nested_text_field(
|
||||||
|
draft_profile,
|
||||||
|
&[
|
||||||
|
"settingText",
|
||||||
|
"creatorIntent.rawSettingText",
|
||||||
|
"creatorIntent.worldHook",
|
||||||
|
"worldHook",
|
||||||
|
"anchorContent.worldPromise",
|
||||||
|
"anchorContent.worldPromise.hook",
|
||||||
|
"summary",
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
let seed = seed_text.trim();
|
||||||
|
(!seed.is_empty()).then(|| seed.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn empty_agent_anchor_content_json() -> String {
|
pub fn empty_agent_anchor_content_json() -> String {
|
||||||
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
|
r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string()
|
||||||
}
|
}
|
||||||
@@ -804,6 +835,32 @@ fn read_text(object: &Map<String, Value>, key: &str) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_nested_text_field(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||||
|
for key in keys {
|
||||||
|
let mut current = Value::Object(object.clone());
|
||||||
|
let mut found = true;
|
||||||
|
for segment in key.split('.') {
|
||||||
|
if let Some(next) = current.get(segment) {
|
||||||
|
current = next.clone();
|
||||||
|
} else {
|
||||||
|
found = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if let Some(value) = current
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
fn read_string_list(object: &Map<String, Value>, key: &str) -> Vec<String> {
|
||||||
object
|
object
|
||||||
.get(key)
|
.get(key)
|
||||||
@@ -955,3 +1012,56 @@ fn build_compiled_profile_payload_json(
|
|||||||
serde_json::to_string(&Value::Object(payload))
|
serde_json::to_string(&Value::Object(payload))
|
||||||
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
|
.map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() {
|
||||||
|
let payload = Map::new();
|
||||||
|
let draft_profile = json!({
|
||||||
|
"settingText": "海雾会吞掉记错航线的人。",
|
||||||
|
"worldHook": "在失真的海图上追查一场被篡改的沉船事故。",
|
||||||
|
"summary": "守灯人与群岛议会围绕沉船旧案对峙。"
|
||||||
|
})
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.expect("draft profile should be object");
|
||||||
|
|
||||||
|
let setting_text =
|
||||||
|
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "");
|
||||||
|
|
||||||
|
assert_eq!(setting_text, "海雾会吞掉记错航线的人。");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_setting_text_prefers_payload_then_draft_then_seed() {
|
||||||
|
let mut payload = Map::new();
|
||||||
|
payload.insert(
|
||||||
|
"settingText".to_string(),
|
||||||
|
Value::String("发布载荷设定".to_string()),
|
||||||
|
);
|
||||||
|
let draft_profile = json!({
|
||||||
|
"worldHook": "草稿世界一句话",
|
||||||
|
"summary": "草稿摘要"
|
||||||
|
})
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.expect("draft profile should be object");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_custom_world_publish_setting_text(&payload, &draft_profile, "用户原始设定"),
|
||||||
|
"发布载荷设定"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_custom_world_publish_setting_text(&Map::new(), &draft_profile, "用户原始设定"),
|
||||||
|
"草稿世界一句话"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_custom_world_publish_setting_text(&Map::new(), &Map::new(), "用户原始设定"),
|
||||||
|
"用户原始设定"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots(
|
|||||||
"rpg",
|
"rpg",
|
||||||
"文字冒险",
|
"文字冒险",
|
||||||
"经典 RPG 体验",
|
"经典 RPG 体验",
|
||||||
"内测",
|
"可创建",
|
||||||
"/creation-type-references/rpg.webp",
|
"/creation-type-references/rpg.webp",
|
||||||
false,
|
true,
|
||||||
true,
|
true,
|
||||||
10,
|
10,
|
||||||
updated_at_micros,
|
updated_at_micros,
|
||||||
|
|||||||
@@ -236,6 +236,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_creation_entry_types_open_rpg_entry() {
|
||||||
|
let configs = default_creation_entry_type_snapshots(1);
|
||||||
|
let rpg = configs
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.id == "rpg")
|
||||||
|
.expect("rpg creation entry should be seeded");
|
||||||
|
|
||||||
|
assert_eq!(rpg.title, "文字冒险");
|
||||||
|
assert_eq!(rpg.subtitle, "经典 RPG 体验");
|
||||||
|
assert!(rpg.visible);
|
||||||
|
assert!(rpg.open);
|
||||||
|
assert_eq!(rpg.badge, "可创建");
|
||||||
|
assert_eq!(rpg.sort_order, 10);
|
||||||
|
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_creation_entry_types_include_bark_battle() {
|
fn default_creation_entry_types_include_bark_battle() {
|
||||||
let configs = default_creation_entry_type_snapshots(1);
|
let configs = default_creation_entry_type_snapshots(1);
|
||||||
|
|||||||
@@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_publish_world_setting_text(
|
||||||
|
payload: &JsonMap<String, JsonValue>,
|
||||||
|
draft_profile: &JsonMap<String, JsonValue>,
|
||||||
|
session: &CustomWorldAgentSession,
|
||||||
|
) -> String {
|
||||||
|
module_custom_world::resolve_custom_world_publish_setting_text(
|
||||||
|
payload,
|
||||||
|
draft_profile,
|
||||||
|
&session.seed_text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_same_agent_draft_profile_candidate(
|
fn is_same_agent_draft_profile_candidate(
|
||||||
row: &CustomWorldProfile,
|
row: &CustomWorldProfile,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
@@ -2608,13 +2620,7 @@ fn execute_publish_world_action(
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| gate.profile_id.clone());
|
.unwrap_or_else(|| gate.profile_id.clone());
|
||||||
let setting_text = payload
|
let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session);
|
||||||
.get("settingText")
|
|
||||||
.and_then(JsonValue::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.unwrap_or_else(|| session.seed_text.clone());
|
|
||||||
let legacy_result_profile_json = payload
|
let legacy_result_profile_json = payload
|
||||||
.get("legacyResultProfile")
|
.get("legacyResultProfile")
|
||||||
.map(serialize_json_value)
|
.map(serialize_json_value)
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrate_rpg_entry_from_old_hidden_default(ctx, now);
|
||||||
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
|
migrate_visual_novel_entry_from_old_visible_default(ctx, now);
|
||||||
migrate_coming_soon_entry_from_old_open_default(
|
migrate_coming_soon_entry_from_old_open_default(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -204,6 +205,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) {
|
||||||
|
let id = "rpg".to_string();
|
||||||
|
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。
|
||||||
|
let still_old_hidden_default = row.title == "文字冒险"
|
||||||
|
&& row.subtitle == "经典 RPG 体验"
|
||||||
|
&& row.badge == "内测"
|
||||||
|
&& row.image_src == "/creation-type-references/rpg.webp"
|
||||||
|
&& !row.visible
|
||||||
|
&& row.open
|
||||||
|
&& row.sort_order == 10;
|
||||||
|
if !still_old_hidden_default {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.db
|
||||||
|
.creation_entry_type_config()
|
||||||
|
.id()
|
||||||
|
.update(CreationEntryTypeConfig {
|
||||||
|
badge: "可创建".to_string(),
|
||||||
|
visible: true,
|
||||||
|
open: true,
|
||||||
|
updated_at: now,
|
||||||
|
..row
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
|
fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
|
||||||
let id = "visual-novel".to_string();
|
let id = "visual-novel".to_string();
|
||||||
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||||
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
|
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
|
||||||
@@ -286,6 +287,40 @@ function ResultViewHarness() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResultViewRehydratingHarness() {
|
||||||
|
const [profile, setProfile] = useState(baseProfile);
|
||||||
|
const [rehydrated, setRehydrated] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="rehydrated">{rehydrated ? 'yes' : 'no'}</div>
|
||||||
|
<RpgCreationResultView
|
||||||
|
profile={profile}
|
||||||
|
previewCharacters={[]}
|
||||||
|
isGenerating={false}
|
||||||
|
progress={0}
|
||||||
|
progressLabel=""
|
||||||
|
error={null}
|
||||||
|
onBack={() => {}}
|
||||||
|
onProfileChange={(nextProfile) => {
|
||||||
|
setProfile(nextProfile);
|
||||||
|
if (!nextProfile.openingCg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const normalized = normalizeCustomWorldProfileRecord(nextProfile);
|
||||||
|
if (normalized) {
|
||||||
|
setProfile(normalized);
|
||||||
|
}
|
||||||
|
setRehydrated(true);
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
@@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
||||||
|
id: 'opening-cg-1',
|
||||||
|
status: 'ready',
|
||||||
|
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
|
||||||
|
storyboardAssetId: 'storyboard-1',
|
||||||
|
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
||||||
|
videoAssetId: 'video-1',
|
||||||
|
imageModel: 'gpt-image-2',
|
||||||
|
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
imageSize: '2k',
|
||||||
|
videoResolution: '480p',
|
||||||
|
durationSeconds: 15,
|
||||||
|
pointCost: 80,
|
||||||
|
estimatedWaitMinutes: 10,
|
||||||
|
updatedAt: '2026-05-03T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ResultViewRehydratingHarness />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('rehydrated').textContent).toBe('yes');
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector(
|
||||||
|
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const profile = {
|
const profile = {
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ const testEntryConfig = {
|
|||||||
description: '先选玩法类型,再进入对应创作工作台。',
|
description: '先选玩法类型,再进入对应创作工作台。',
|
||||||
},
|
},
|
||||||
creationTypes: [
|
creationTypes: [
|
||||||
|
{
|
||||||
|
id: 'rpg',
|
||||||
|
title: '文字冒险',
|
||||||
|
subtitle: '经典 RPG 体验',
|
||||||
|
badge: '可创建',
|
||||||
|
imageSrc: '/creation-type-references/rpg.webp',
|
||||||
|
visible: true,
|
||||||
|
open: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
updatedAtMicros: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
title: '拼图',
|
title: '拼图',
|
||||||
@@ -253,14 +264,18 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
|||||||
const match3dButton = screen.getByRole('button', {
|
const match3dButton = screen.getByRole('button', {
|
||||||
name: /抓大鹅.*3D 消除关卡/u,
|
name: /抓大鹅.*3D 消除关卡/u,
|
||||||
});
|
});
|
||||||
|
const rpgButton = screen.getByRole('button', {
|
||||||
|
name: /文字冒险.*经典 RPG 体验/u,
|
||||||
|
});
|
||||||
expect(puzzleButton).toBeTruthy();
|
expect(puzzleButton).toBeTruthy();
|
||||||
expect(match3dButton).toBeTruthy();
|
expect(match3dButton).toBeTruthy();
|
||||||
|
expect(rpgButton).toBeTruthy();
|
||||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull();
|
||||||
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
|
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull();
|
|
||||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||||
|
|
||||||
await user.click(match3dButton);
|
await user.click(match3dButton);
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ const testEntryConfig = {
|
|||||||
description: '先选玩法类型,再进入对应创作工作台。',
|
description: '先选玩法类型,再进入对应创作工作台。',
|
||||||
},
|
},
|
||||||
creationTypes: [
|
creationTypes: [
|
||||||
|
{
|
||||||
|
id: 'rpg',
|
||||||
|
title: '文字冒险',
|
||||||
|
subtitle: '经典 RPG 体验',
|
||||||
|
badge: '可创建',
|
||||||
|
imageSrc: '/creation-type-references/rpg.webp',
|
||||||
|
visible: true,
|
||||||
|
open: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
updatedAtMicros: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
title: '拼图',
|
title: '拼图',
|
||||||
@@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
|||||||
expect(html).toContain('拼图关卡创作');
|
expect(html).toContain('拼图关卡创作');
|
||||||
expect(html).toContain('抓大鹅');
|
expect(html).toContain('抓大鹅');
|
||||||
expect(html).toContain('3D 消除关卡');
|
expect(html).toContain('3D 消除关卡');
|
||||||
expect(html).not.toContain('文字冒险');
|
expect(html).toContain('文字冒险');
|
||||||
|
expect(html).toContain('经典 RPG 体验');
|
||||||
expect(html).not.toContain('大鱼吃小鱼');
|
expect(html).not.toContain('大鱼吃小鱼');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,17 @@ const testCreationEntryConfig = {
|
|||||||
description: '先选玩法类型,再进入对应创作工作台。',
|
description: '先选玩法类型,再进入对应创作工作台。',
|
||||||
},
|
},
|
||||||
creationTypes: [
|
creationTypes: [
|
||||||
|
{
|
||||||
|
id: 'rpg',
|
||||||
|
title: '文字冒险',
|
||||||
|
subtitle: '经典 RPG 体验',
|
||||||
|
badge: '可创建',
|
||||||
|
imageSrc: '/creation-type-references/rpg.webp',
|
||||||
|
visible: true,
|
||||||
|
open: true,
|
||||||
|
sortOrder: 10,
|
||||||
|
updatedAtMicros: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
title: '拼图',
|
title: '拼图',
|
||||||
@@ -3205,8 +3216,8 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
|||||||
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
||||||
).toContain('/creation-type-references/puzzle.webp');
|
).toContain('/creation-type-references/puzzle.webp');
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
|
||||||
).toContain('/creation-type-references/airp.webp');
|
).toContain('/creation-type-references/rpg.webp');
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
||||||
).toContain('/creation-type-references/match3d.webp');
|
).toContain('/creation-type-references/match3d.webp');
|
||||||
|
|||||||
@@ -165,4 +165,35 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
|||||||
'/generated-characters/story-yizhang/portrait.png',
|
'/generated-characters/story-yizhang/portrait.png',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('保留结果页生成的开局 CG 槽位', () => {
|
||||||
|
const profile = normalizeCustomWorldProfileRecord({
|
||||||
|
name: '雾港归航',
|
||||||
|
settingText: '海雾旧案',
|
||||||
|
openingCg: {
|
||||||
|
id: 'opening-cg-1',
|
||||||
|
status: 'ready',
|
||||||
|
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||||||
|
storyboardAssetId: 'storyboard-1',
|
||||||
|
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||||||
|
videoAssetId: 'video-1',
|
||||||
|
imageModel: 'gpt-image-2',
|
||||||
|
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||||
|
aspectRatio: '16:9',
|
||||||
|
imageSize: '2k',
|
||||||
|
videoResolution: '480p',
|
||||||
|
durationSeconds: 15,
|
||||||
|
pointCost: 80,
|
||||||
|
estimatedWaitMinutes: 10,
|
||||||
|
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile?.openingCg?.videoSrc).toBe(
|
||||||
|
'/generated-custom-world-scenes/opening/opening.mp4',
|
||||||
|
);
|
||||||
|
expect(profile?.openingCg?.storyboardImageSrc).toBe(
|
||||||
|
'/generated-custom-world-scenes/opening/storyboard.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
CustomWorldNpcVisualGear,
|
CustomWorldNpcVisualGear,
|
||||||
CustomWorldNpcVisualGearType,
|
CustomWorldNpcVisualGearType,
|
||||||
CustomWorldNpcVisualRace,
|
CustomWorldNpcVisualRace,
|
||||||
|
CustomWorldOpeningCgProfile,
|
||||||
CustomWorldPlayableNpc,
|
CustomWorldPlayableNpc,
|
||||||
CustomWorldProfile,
|
CustomWorldProfile,
|
||||||
CustomWorldRoleInitialItem,
|
CustomWorldRoleInitialItem,
|
||||||
@@ -1155,6 +1156,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||||
: [];
|
: [];
|
||||||
|
const openingCg = preserveStructuredRecord<CustomWorldOpeningCgProfile>(
|
||||||
|
value.openingCg,
|
||||||
|
);
|
||||||
const normalizedProfile = {
|
const normalizedProfile = {
|
||||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||||
settingText,
|
settingText,
|
||||||
@@ -1180,6 +1184,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
|||||||
.map((entry, index) => normalizeItem(entry, index))
|
.map((entry, index) => normalizeItem(entry, index))
|
||||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||||
: [],
|
: [],
|
||||||
|
openingCg,
|
||||||
camp,
|
camp,
|
||||||
landmarks: normalizeCustomWorldLandmarks({
|
landmarks: normalizeCustomWorldLandmarks({
|
||||||
landmarks: landmarkDrafts,
|
landmarks: landmarkDrafts,
|
||||||
|
|||||||
Reference in New Issue
Block a user