From 41075e41a210cb5e2c43ba78cc9c757c2674fb75 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 21 May 2026 17:21:38 +0800 Subject: [PATCH] fix: stabilize rpg creation entry and opening cg --- .gitignore | 1 + .hermes/shared-memory/decision-log.md | 16 ++ .hermes/shared-memory/pitfalls.md | 24 ++ ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 5 + ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 4 + ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 18 ++ server-rs/crates/api-server/src/app.rs | 25 ++ server-rs/crates/api-server/src/config.rs | 23 +- .../api-server/src/creation_agent_llm_turn.rs | 119 +++++++++- .../api-server/src/creation_entry_config.rs | 28 +++ .../crates/api-server/src/custom_world_ai.rs | 90 +++++++- server-rs/crates/api-server/src/match3d.rs | 5 +- .../api-server/src/match3d/item_assets.rs | 4 +- .../crates/api-server/src/match3d/tests.rs | 4 +- .../api-server/src/openai_image_generation.rs | 214 ++++++++++++++++-- .../module-custom-world/src/application.rs | 110 +++++++++ .../crates/module-runtime/src/application.rs | 4 +- server-rs/crates/module-runtime/src/lib.rs | 17 ++ .../spacetime-module/src/custom_world.rs | 20 +- .../src/runtime/creation_entry_config.rs | 31 +++ src/components/CustomWorldResultView.test.tsx | 69 ++++++ ...ustomWorldCreationHub.interaction.test.tsx | 17 +- .../CustomWorldCreationHub.test.tsx | 14 +- ...gEntryFlowShell.agent.interaction.test.tsx | 15 +- src/data/customWorldLibrary.test.ts | 31 +++ src/data/customWorldLibrary.ts | 5 + 26 files changed, 866 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 610ac88f..c90efe5c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ temp*build*/ /.codex-temp /target/ /logs +/server-rs/crates/*/logs/ .worktrees/ .env.secrets.local spacetime.local.json diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 783fabbd..8aef4e70 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -113,6 +113,14 @@ - éªŒè¯æ–¹å¼ï¼šæ–°å¢žçŽ©æ³• 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`。 +## 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 é€šç”¨æ¨¡å— - èƒŒæ™¯ï¼šæŠ“å¤§é¹…ç‰©å“ sheet å·²åŒ…å« prompt 组装ã€å›ºå®šç½‘格切图ã€ç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–ã€åˆ‡ç‰‡ PNG æŒä¹…化和 prompt 追踪;继续留在 Match3D ç§æœ‰æ¨¡å—会让跳一跳ã€åŽç»­åœ°å— / é“具类玩法é‡å¤å¤åˆ¶åŒä¸€å¥—算法和 OSS 元数æ®å£å¾„。 @@ -349,6 +357,14 @@ - éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œå…¥å£é…ç½®ã€åˆ›ä½œ 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`。 +## 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 è¿è¡Œæ€è¾“入设备抽象层全项目通用化 - 背景:拼图è¿è¡Œæ€æŽ¥å…¥ mocap åŽï¼Œé¼ æ ‡/触控和 mocap å„自维护输入逻辑会导致åˆå¹¶å¤§å—ã€æ‹–æ‹½è¯­ä¹‰å’Œå–æ¶ˆä¼šè¯è¡Œä¸ºä¸ä¸€è‡´ï¼›åŽç»­å…¶ä»–玩法也需è¦å¤ç”¨ä½“æ„Ÿã€æ‘‡æ†ã€é”®ç›˜ç­‰è®¾å¤‡è¾“入。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0399e186..88b413fb 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,14 @@ - 验è¯ï¼šæ‹¼å›¾å…¥å£æµ‹è¯•ä»å¯é€šè¿‡ï¼Œä¸”新组件å¯é€šè¿‡ä¸åŒé¡µé¢å¤ç”¨è€Œä¸éœ€è¦å¤åˆ¶ä¸Šä¼ å¡å®žçŽ°ã€‚ - å…³è”:`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 ä¸‹è½½æˆªæ–­è¦æ–­ç‚¹ç»­ä¼ è€Œä¸æ˜¯å›žé€€ç›®æ ‡æœºä¸‹è½½ - 现象:`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`。 - å…³è”:`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 首部超时 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已ç»è®¾ç½®è¾ƒé•¿çš„ AbortController 超时,但ä»åœ¨çº¦ 180 到 300 ç§’åŽæŠ› `AbortError`ã€`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`ï¼›åŒä¸€ prompt 改用原生 `https.request` å¯ä»¥åœ¨è¾ƒçŸ­æ—¶é—´å†…æˆåŠŸè¿”å›žå›¾ç‰‡ã€‚ diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 125fd0d0..0986e5db 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -325,6 +325,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldAgentSession` - æºç ï¼š`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` @@ -597,6 +598,10 @@ npm run check:server-rs-ddd `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。 ### `quest_log` diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index 42527230..460c1338 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -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_*` å£å¾„。 +本地 `.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 日志: ```bash @@ -213,6 +215,8 @@ OpenTelemetry çŽ°é˜¶æ®µé»˜è®¤å¼€å¯ OTLP traces / metrics / logs,但本地日 - `WECHAT_*` - `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` é‡å¯åŽï¼Œå·²å‘é€ä½†æœªæ ¡éªŒçš„验è¯ç ä¼šå¤±æ•ˆã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index f078760e..eb07fc54 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -34,6 +34,24 @@ 6. 点击 `generationStatus=generating` çš„è‰ç¨¿å¡å¿…é¡»æ¢å¤å¯¹åº”玩法的生æˆè¿›åº¦é¡µï¼Œä¸èƒ½è¿›å…¥ç©ºç™½ç»“果页或普通工作区;æ¢å¤ç”Ÿæˆé¡µçš„ `startedAtMs` ä½¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 推导。 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` ä¸¢æŽ‰ï¼Œè€Œä¸æ˜¯å…ˆæ€€ç–‘已生æˆèµ„æºæœ¬èº«å¤±æ•ˆã€‚ + ## 拼图 当剿‹¼å›¾é“¾è·¯ï¼š diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d540f418..6560dd05 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -619,6 +619,31 @@ mod tests { 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] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 0398c948..079f7b5a 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -260,8 +260,11 @@ impl Default for AppConfig { llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, - rpg_llm_web_search_enabled: true, - creation_agent_llm_web_search_enabled: true, + // 中文注释:创作/RPG 的结构化 JSON 链路默认ä¸å¯ç”¨ Responses web_search。 + // 未开通工具的上游会先å自然语言å†è¿”回 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_api_key: None, 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] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK @@ -1476,11 +1487,11 @@ mod tests { unsafe { 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(); - assert!(!config.rpg_llm_web_search_enabled); + assert!(config.rpg_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); @@ -1496,11 +1507,11 @@ mod tests { unsafe { 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(); - assert!(!config.creation_agent_llm_web_search_enabled); + assert!(config.creation_agent_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 3a2cf825..86bbfa93 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -93,15 +93,74 @@ where F: FnMut(&str), { 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( + 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 +where + F: FnMut(&str), +{ let response = llm_client .stream_text( build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { + if !emit_reply_updates { + return; + } if let Some(reply_progress) = 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()); } }, @@ -110,12 +169,6 @@ where .map_err(CreationAgentJsonTurnFailure::Stream)?; let parsed = parse_json_response_text(response.content.as_str()) .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 }) } @@ -327,6 +380,7 @@ mod tests { let server = spawn_capturing_mock_server(vec![ MockResponse { 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: [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 { body: String, } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index c470beba..24e471d4 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/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") { return Some("visual-novel"); } @@ -161,6 +169,26 @@ mod tests { resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), 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!( resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), Some("bark-battle"), diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 12280439..649999dd 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,7 +10,9 @@ use axum::{ response::Response, }; 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::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, 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_VIDEO_SLOT: &str = "opening_cg_video"; 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 { opening_act_title: String, @@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg( "openingSceneImageSrc", ) .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( &state, &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 { + 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( http_client: &reqwest::Client, settings: &DashScopeSettings, @@ -3065,6 +3125,34 @@ mod tests { 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] fn push_cover_reference_source_keeps_full_data_url() { let mut sources = Vec::new(); diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 75f731b3..3a599422 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_ITEM_SIZE_LARGE: &str = "大"; const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中"; const MATCH3D_ITEM_SIZE_SMALL: &str = "å°"; -const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!( - "../../../../public/match3d-background-references/pot-fused-reference.png" -); +const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = + include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png"); const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题æ"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "éœ€è¦æ¶ˆé™¤å¤šå°‘次æ‰èƒ½é€šå…³"; diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 87f86542..51c270ed 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1,12 +1,12 @@ use super::*; +#[cfg(test)] +use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; use crate::generated_asset_sheets::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, 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( state: &AppState, diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 04b93cbc..74417201 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -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.file_name, "match3d-container-reference.png"); 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" ); } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 55554701..11b754f6 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{error::Error as _, time::Duration}; use axum::http::StatusCode; 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::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) - // 中文注释:åŒä¸€å®¢æˆ·ç«¯ä¹Ÿä¼šæ‰¿è½½ `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 å¯é¿å¼€éƒ¨åˆ†ç½‘关对 HTTP/2 multipart æµçš„兼容问题。 - .http1_only() .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ @@ -110,21 +108,46 @@ pub(crate) async fn create_openai_image_generation( candidate_count, 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 - .post(vector_engine_images_generation_url(settings)) + .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) + .body(request_body_bytes) .send() .await .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片生æˆä»»åŠ¡å¤±è´¥ï¼š{error}" - )) + map_openai_image_reqwest_error( + format!("{failure_context}:创建图片生æˆä»»åŠ¡å¤±è´¥").as_str(), + request_url.as_str(), + error, + ) })?; let response_status = response.status(); 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("size", normalize_image_size(size)); - let response = http_client - .post(vector_engine_images_edit_url(settings).as_str()) + let request_url = vector_engine_images_edit_url(settings); + // 中文注释:åªå¯¹ 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::AUTHORIZATION, format!("Bearer {}", settings.api_key), @@ -202,9 +228,11 @@ pub(crate) async fn create_openai_image_edit( .send() .await .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片编辑任务失败:{error}" - )) + map_openai_image_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + error, + ) })?; let response_status = response.status(); 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::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( prompt: &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::>() + .join(" ") +} + +fn reqwest_error_source_chain(error: &reqwest::Error) -> Vec { + 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::>() + .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::>() + .join(",") +} + fn map_openai_image_upstream_error( upstream_status: u16, 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] fn b64_json_response_decodes_png_image() { let images = images_from_base64( diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index 0cf077d7..a518b3d1 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo true } +pub fn resolve_custom_world_publish_setting_text( + payload: &Map, + draft_profile: &Map, + 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 { 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, key: &str) -> Option { .map(ToOwned::to_owned) } +fn read_nested_text_field(object: &Map, keys: &[&str]) -> Option { + 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, key: &str) -> Vec { object .get(key) @@ -955,3 +1012,56 @@ fn build_compiled_profile_payload_json( serde_json::to_string(&Value::Object(payload)) .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(), "用户原始设定"), + "用户原始设定" + ); + } +} diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index c3ebf645..87002c8a 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots( "rpg", "文字冒险", "ç»å…¸ RPG 体验", - "内测", + "å¯åˆ›å»º", "/creation-type-references/rpg.webp", - false, + true, true, 10, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3d182426..490a2def 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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] fn default_creation_entry_types_include_bark_battle() { let configs = default_creation_entry_type_snapshots(1); diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 36228bfe..c7d8eeca 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id( } } +fn resolve_publish_world_setting_text( + payload: &JsonMap, + draft_profile: &JsonMap, + 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( row: &CustomWorldProfile, owner_user_id: &str, @@ -2608,13 +2620,7 @@ fn execute_publish_world_action( .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| gate.profile_id.clone()); - let setting_text = payload - .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 setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session); let legacy_result_profile_json = payload .get("legacyResultProfile") .map(serialize_json_value) diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 05c9db50..c0b47b2e 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -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_coming_soon_entry_from_old_open_default( 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) { let id = "visual-novel".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 2142efe8..cf381a5f 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { expect, test, vi } from 'vitest'; +import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; 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 ( +
+
{rehydrated ? 'yes' : 'no'}
+ {}} + onProfileChange={(nextProfile) => { + setProfile(nextProfile); + if (!nextProfile.openingCg) { + return; + } + + window.setTimeout(() => { + const normalized = normalizeCustomWorldProfileRecord(nextProfile); + if (normalized) { + setProfile(normalized); + } + setRehydrated(true); + }, 0); + }} + /> +
+ ); +} + test('clickingæ–°å¢žå¯æ‰®æ¼”角色 shows pending item, disables button, and marks result as new', async () => { 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(); + + 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 () => { const user = userEvent.setup(); const profile = { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 9c2d722e..dbd41fc3 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -25,6 +25,17 @@ const testEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', title: '拼图', @@ -253,14 +264,18 @@ test('creation hub reflects updated draft title summary and counts after rerende const match3dButton = screen.getByRole('button', { name: /抓大鹅.*3D 消除关å¡/u, }); + const rpgButton = screen.getByRole('button', { + name: /文字冒险.*ç»å…¸ RPG 体验/u, + }); expect(puzzleButton).toBeTruthy(); expect(match3dButton).toBeTruthy(); + expect(rpgButton).toBeTruthy(); expect((puzzleButton 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.queryByText('å直觉形状分拣')).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); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 8ece1b8b..ced0e82c 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -19,6 +19,17 @@ const testEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', title: '拼图', @@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('拼图关å¡åˆ›ä½œ'); expect(html).toContain('抓大鹅'); expect(html).toContain('3D 消除关å¡'); - expect(html).not.toContain('文字冒险'); + expect(html).toContain('文字冒险'); + expect(html).toContain('ç»å…¸ RPG 体验'); expect(html).not.toContain('大鱼åƒå°é±¼'); }); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 732dc6f6..053e09bb 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -271,6 +271,17 @@ const testCreationEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', 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, ).toContain('/creation-type-references/puzzle.webp'); expect( - screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src, - ).toContain('/creation-type-references/airp.webp'); + screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src, + ).toContain('/creation-type-references/rpg.webp'); expect( screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src, ).toContain('/creation-type-references/match3d.webp'); diff --git a/src/data/customWorldLibrary.test.ts b/src/data/customWorldLibrary.test.ts index e07f9e62..6643c7b2 100644 --- a/src/data/customWorldLibrary.test.ts +++ b/src/data/customWorldLibrary.test.ts @@ -165,4 +165,35 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => { '/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', + ); + }); }); diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index e075867d..8b53f3e8 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -27,6 +27,7 @@ import { CustomWorldNpcVisualGear, CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, + CustomWorldOpeningCgProfile, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, @@ -1155,6 +1156,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizePlayableNpc(entry, index)) .filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry)) : []; + const openingCg = preserveStructuredRecord( + value.openingCg, + ); const normalizedProfile = { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, @@ -1180,6 +1184,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizeItem(entry, index)) .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], + openingCg, camp, landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts,