Auto-open draft result after foundation completes

This commit is contained in:
2026-04-25 10:52:39 +08:00
parent 35c2bce6f1
commit 03acbc5cb1
31 changed files with 36472 additions and 232 deletions

35281
.codex/tmp-schema.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,3 +38,15 @@
2. 确认前端不再通过任何路径调用 Node 后端能力。 2. 确认前端不再通过任何路径调用 Node 后端能力。
3. 删除旧脚本、旧 smoke、旧 manifest 与 `server-node/` 目录。 3. 删除旧脚本、旧 smoke、旧 manifest 与 `server-node/` 目录。
4. 删除冻结基线检查中对历史引用的豁免。 4. 删除冻结基线检查中对历史引用的豁免。
## 6. 已确认迁移项
### 6.1 场景幕背景图提示词
2026-04-25 已把旧 Node 自动资产链路中的场景幕背景图提示词包装迁移到 Rust 主线:
1. 旧来源:`server-node/src/services/customWorldAgentAutoAssetService.ts``buildSceneActPrompt(...)`
2. 新主源:`server-rs/crates/api-server/src/custom_world.rs``build_scene_act_background_image_prompt(...)`
3. 使用位置:`generate_draft_foundation_act_backgrounds(...)` 收集 `sceneChapterBlueprints[].acts[]` 后,先构造幕背景图专用提示词,再调用 `generate_custom_world_scene_image_for_profile(...)`
4. 保留语义:世界名、场景名、幕标题、幕摘要、幕目标、过渡钩子、主角色、辅助角色、世界气质、背景描述,以及“只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字”的约束。
5. 迁移边界:`server-node/` 仅作为历史来源说明,不再参与运行;后续调整统一修改 Rust 主源。

View File

@@ -249,7 +249,19 @@
3. 针对初始同伴流程补一份单独的状态图 / 时序图 3. 针对初始同伴流程补一份单独的状态图 / 时序图
4. 对大 chunk 警告做代码分包 4. 对大 chunk 警告做代码分包
## 14. 一句话总结 ## 14. SpacetimeDB 绑定桥接层要做同名去重
`server-rs/crates/spacetime-client` 里有一部分内容是围绕 SpacetimeDB 生成绑定补的手写桥接层。
经验:
- 新增 procedure、input type 或 mapper 时,先全局确认 `module_bindings/mod.rs``mapper.rs`、业务封装文件里是否已经存在同名声明
- `module_bindings/mod.rs` 同一个模块只保留一条 `pub mod` 和一条 `pub use`,不要同时放在 reducer 区和 procedure 区
- `mapper.rs` 的字符串枚举解析函数、API 入参结构只保留一个权威定义,业务侧统一复用
- 业务封装文件里同一个 procedure 只暴露一个客户端方法,避免 Rust 在编译期出现 E0428、E0252、E0119、E0592 这类重复定义错误
- 修复重复绑定时优先删除后追加的重复块,不要重写整文件,避免影响中文注释和生成绑定附近的大段内容
## 15. 一句话总结
这个项目真正的开发经验不是“怎么多写一个按钮”,而是: 这个项目真正的开发经验不是“怎么多写一个按钮”,而是:

View File

@@ -24,3 +24,9 @@
- 新草稿中每一幕的 `backgroundPromptText` 应该像自然的画面描述,包含主体、前中远景、站位空间、氛围识别点。 - 新草稿中每一幕的 `backgroundPromptText` 应该像自然的画面描述,包含主体、前中远景、站位空间、氛围识别点。
- 不应再出现“第1幕背景玩家会在……”这类明显拼接句。 - 不应再出现“第1幕背景玩家会在……”这类明显拼接句。
- 如果 LLM 漏掉 `actBackgroundPromptTexts`,生成幕背景图阶段应失败并提示缺少 `backgroundPromptText`,而不是静默使用拼接文案。 - 如果 LLM 漏掉 `actBackgroundPromptTexts`,生成幕背景图阶段应失败并提示缺少 `backgroundPromptText`,而不是静默使用拼接文案。
## 2026-04-24 并发限流错误处理补充
- 批量生成幕背景图时,`JoinSet` 子任务的成功值和失败值固定承载 `(chapter_index, act_index, message)`,用于把错误精确标记回对应章节幕。
- `Semaphore::acquire``AcquireError` 不能在子任务中转成裸 `String` 后直接使用 `?`,否则会破坏子任务统一错误类型并导致 `E0277`
- 限流器异常应映射为同一组三元组错误,保持后续 `mark_scene_act_background_generation_error` 和部分成功保留逻辑可复用。

View File

@@ -21,8 +21,11 @@ RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景
- 背景分支使用 `JoinSet``sceneChapterBlueprints[*].acts[*]` 的每一幕背景任务一次性投递,返回后写入 `backgroundImageSrc``backgroundAssetId``generatedScenePrompt``generatedSceneModel` - 背景分支使用 `JoinSet``sceneChapterBlueprints[*].acts[*]` 的每一幕背景任务一次性投递,返回后写入 `backgroundImageSrc``backgroundAssetId``generatedScenePrompt``generatedSceneModel`
- `merge_generated_act_backgrounds` 只把背景图字段合并回角色分支副本,再进入后续草稿卡编译和 SpacetimeDB 写入。 - `merge_generated_act_backgrounds` 只把背景图字段合并回角色分支副本,再进入后续草稿卡编译和 SpacetimeDB 写入。
- 幕背景 prompt 同时兼容 `backgroundPromptText``scenePromptText``visualPromptText``promptText``imagePromptText``backgroundPrompt``visualPrompt`,避免 LLM 输出字段别名导致整批背景图被误判缺失。 - 幕背景 prompt 同时兼容 `backgroundPromptText``scenePromptText``visualPromptText``promptText``imagePromptText``backgroundPrompt``visualPrompt`,避免 LLM 输出字段别名导致整批背景图被误判缺失。
- 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。任一单项超过 3 次仍失败时,后台任务必须把 operation 标记为 `failed` 并停止写入草稿卡,避免生成“缺主图 / 缺背景图”的可进入世界档案。\r\n- 图片任务仍然一次性投递,保证角色与幕背景两类任务不回退到串行编排;但真正请求上游生图服务时必须共用并发闸门,当前同一底稿最多同时发起 2 个上游请求,降低 DashScope 瞬时 502 / 限流导致整批失败的概率。\r\n- 幕背景图失败文案必须带第几章、第几幕和幕标题不能只显示“第1幕 / 第2幕 / 第3幕”否则多章节同名幕会被用户误认为同一失败项重复上报 - 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。幕背景图允许部分成功:只要至少一幕成功,就必须保留已成功写入的 `backgroundImageSrc` 并继续生成草稿卡;全部幕都失败时才把素材阶段标记为“生成幕背景图失败”
- 中止前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId` - 图片任务仍然一次性投递,保证角色与幕背景两类任务不回退到串行编排;但真正请求上游生图服务时必须共用并发闸门。并发数由 `GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS``DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS` 配置,默认 4避免固定为 2 导致多角色、多幕草稿总耗时过长
- 幕背景图失败文案必须带第几章、第几幕和幕标题不能只显示“第1幕 / 第2幕 / 第3幕”否则多章节同名幕会被用户误认为同一失败项重复上报。
- 中止或部分失败前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId`
- 每一幕自动生图必须记录 operation、session、第几章、第几幕、sceneId、sceneName、attempt、elapsedMs 与供应商真实错误,避免再次出现只看到“生成幕背景图失败”但无法定位哪张图、哪次请求、哪个上游原因的问题。
- 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。 - 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。
## 后续注意 ## 后续注意

View File

@@ -43,3 +43,11 @@
3. SpacetimeDB 写入必须通过 `spacetime-client` 已生成绑定,不在 reducer 中访问网络或文件系统。 3. SpacetimeDB 写入必须通过 `spacetime-client` 已生成绑定,不在 reducer 中访问网络或文件系统。
4. 所有新增 Rust 代码保留中文注释,且只做局部修改,避免重写包含中文的大文件。 4. 所有新增 Rust 代码保留中文注释,且只做局部修改,避免重写包含中文的大文件。
## 6. 失败排查原文日志
1. RPG 草稿生成链路的模型输入与模型输出原文日志统一收口在 `platform-llm` 网关层,避免每个模板调用点重复实现。
2. 只有发生请求失败、上游非 2xx、响应读取失败、JSON/SSE 解析失败或空响应时,才将本次模型输入与已拿到的模型输出原文分别写入文件;正常成功生成不默认落盘原文,避免日志体积不可控。
3. 日志目录默认使用仓库运行目录下的 `logs/llm-raw`,可通过 `LLM_RAW_LOG_DIR` 覆盖;每次失败写成同一 trace 前缀下的 `*.input.json``*.output.txt` 两个 UTF-8 文件。
4. `*.input.json` 记录 provider、model、stream、attempt、maxTokens 与完整 messages`*.output.txt` 记录上游 HTTP 原文、非流式响应原文、SSE 原始事件文本,或请求尚未到达上游时的错误摘要。
5. 文件名只使用时间戳、进程号、递增序号与安全化错误阶段不包含用户输入、sessionId 或 API key输入 JSON 不写入 API key。
6. 文件日志失败只写 warn不影响草稿生成主错误返回该日志仅用于本地开发与排障不作为 SpacetimeDB 真相态。

View File

@@ -0,0 +1,35 @@
# 创作 Agent 发布门槛结果页归一化回写修正
日期:`2026-04-24`
## 1. 问题现象
`custom_world.publish_gate` 诊断日志显示:
1. `has_draft_profile=true`
2. `has_result_preview=true`
3. `has_world_hook=true`
4. `has_core_conflicts=true`
5. 但仍存在 `publish_missing_player_premise / publish_missing_main_chapter / publish_missing_first_act`
这说明接口可正常读取 session问题不在 `GET /api/runtime/custom-world/agent/sessions/:sessionId` 本身,而在结果页 profile 回写到 session 时,发布门槛需要的部分结构字段没有稳定保留下来。
## 2. 根因
前端结果页通过 `normalizeCustomWorldProfileRecord``resultPreview.preview` 转成 `CustomWorldProfile`。该归一化模型原本主要服务作品库与运行时展示,只保留了 `settingText / summary / playerGoal / creatorIntent / anchorContent / sceneChapterBlueprints` 等字段,没有把后端发布门槛直接读取的顶层 `worldHook / playerPremise` 纳入 `CustomWorldProfile` 稳定字段。
当自动保存或发布前执行 `sync_result_profile` 时,前端会把归一化后的 profile 传回 SpacetimeDB。若这份 profile 中缺少顶层 `playerPremise`,且 `creatorIntent / anchorContent` 又未包含可读玩家切入字段,后端最终 publish gate 会继续报 `publish_missing_player_premise`
## 3. 修复口径
1. `CustomWorldProfile` 显式声明 `worldHook / playerPremise` 为 Agent 发布快照兼容字段。
2. `normalizeCustomWorldProfileRecord` 保留顶层 `worldHook / playerPremise`,并在缺失时从 `creatorIntent.worldHook / creatorIntent.playerPremise / summary / playerGoal` 做最小回填。
3. 不在 UI 新增规则说明文案;这两个字段只作为后端发布门槛与 session 回写的稳定数据槽位。
4. 后端 publish gate 继续以 SpacetimeDB 中的 `draft_profile_json` 为最终真相源,前端只负责把结果页当前 profile 完整同步回去。
## 4. 验收标准
1.`resultPreview.preview` 构建结果页 profile 后,`worldHook / playerPremise` 不会被前端归一化丢弃。
2. 自动保存或点击发布前执行 `sync_result_profile` 时,传回后端的 profile 保留发布门槛所需顶层字段。
3. 若当前草稿确实包含玩家切入与 `sceneChapterBlueprints[*].acts`,后端诊断日志不应再出现对应结构 blocker。
4. 若草稿真实缺失章节或第一幕,`publish_missing_main_chapter / publish_missing_first_act` 仍应保留,不做前端假放行。

View File

@@ -52,3 +52,14 @@
- 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。 - 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。
- 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。 - 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。
- UI 不默认展示规则解释文案,正式约束只进入后端 prompt。 - UI 不默认展示规则解释文案,正式约束只进入后端 prompt。
## 5. 自动草稿素材回写约束
- 世界草稿自动素材生成与草稿页手动生成使用同一套 `server-rs/crates/api-server/src/custom_world_ai.rs` 场景图接口和 OSS/SpacetimeDB 资产持久化链路。
- 自动批量生成幕背景时,后端必须把已成功生成的 `backgroundImageSrc/backgroundAssetId/generatedScenePrompt/generatedSceneModel` 写回 `sceneChapterBlueprints[*].acts[*]`,不能因为同批某一幕失败而丢弃已成功图片。
- 某一幕连续重试仍失败时,只允许在该幕写入 `backgroundGenerationError` 作为诊断字段;只要至少一幕成功,草稿仍应完成并让前端展示成功图片。
- 只有全部幕背景均失败时,才把“生成幕背景图失败”作为草稿素材阶段失败原因保存。
- Rust 服务实际生图模型读取 `DASHSCOPE_SCENE_IMAGE_MODEL` / `DASHSCOPE_COVER_IMAGE_MODEL` / `DASHSCOPE_REFERENCE_IMAGE_MODEL`;兼容旧 `DASHSCOPE_IMAGE_MODEL`,避免 `.env.example` 中配置了模型但服务端仍使用硬编码模型。
- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope它必须像草稿页手动生成一样把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description/dangerLevel` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。
- 自动草稿幕背景的默认尺寸必须与草稿页手动生成默认尺寸一致,当前统一为 `1280*720`;不能在自动链路中单独改成 `1600*900`,否则同一 prompt 在同一模型下也可能因供应商尺寸支持或耗时不同而表现不一致。
- 批量自动生图失败日志必须保留 `AppError.details.message` 中的供应商真实原因,不能只记录 `AppError.message()` 的 HTTP 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。

View File

@@ -4,6 +4,8 @@
## 文档列表 ## 文档列表
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。 - [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。 - [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。 - [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。

View File

@@ -0,0 +1,26 @@
# RPG 生成流程刷新恢复与即时持久化设计2026-04-24
## 背景
- RPG 共创从 Agent 聊天页触发 `draft_foundation` 后进入生成过程页。
- 旧实现只持久化 `activeSessionId``activeOperationId`,刷新时恢复入口会无条件回到 Agent 聊天页。
- operation 失败后继续创作也会因为 operation 指针被清空而缺失生成页上下文。
## 目标
1. 生成中刷新网页后仍停留在生成过程页。
2. 生成完成后结果页内容第一时间落入作品持久化链路。
3. 生成失败后从创作入口继续处理该草稿时,优先回到生成过程页展示失败状态,而不是 Agent 聊天页。
## 落地规则
- 前端只保存恢复指针,不在 UI 持久层复制世界数据。
- `sessionStorage` 与 URL query 中增加生成页来源字段 `customWorldGenerationSource`,当前仅支持 `agent-draft-foundation`
- 初始恢复时:
- 若存在 `activeOperationId` 且来源为 `agent-draft-foundation`,先进入 `custom-world-generating`
- 否则若 session 已经可构建结果预览,进入 `custom-world-result`
- 其他情况进入 `agent-workspace`
- operation 进入 `completed``failed` 后仍保留 `activeOperationId`,直到用户离开、重新发起操作或清理工作区,保证刷新和继续创作能恢复完成/失败状态。
- 生成完成后由 `useRpgCreationResultAutosave` 在结果页立即保存。生成页跳结果页前必须先同步最新 session 并写入 `generatedCustomWorldProfile`,确保自动保存消费的是最新快照。
## 验收点
- 生成中刷新URL/sessionStorage 可恢复 `custom-world-generating`,页面显示“世界草稿生成进度”。
- 生成失败刷新或继续创作:页面仍显示生成过程页和失败信息,不展示 Agent 聊天页。
- 生成完成:跳到结果页后触发 `upsertRpgWorldProfile`,保存请求带 `sourceAgentSessionId`

View File

@@ -315,7 +315,7 @@ fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String { fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack)) serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
.unwrap_or_else(|_| "{}".to_string()) .unwrap_or_else(|_| "{}".to_string())
} }
fn map_big_fish_record_anchor_pack( fn map_big_fish_record_anchor_pack(

View File

@@ -82,7 +82,11 @@ pub struct AppConfig {
pub llm_retry_backoff_ms: u64, pub llm_retry_backoff_ms: u64,
pub dashscope_base_url: String, pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>, pub dashscope_api_key: Option<String>,
pub dashscope_scene_image_model: String,
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64, pub dashscope_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String, pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>, pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64, pub ark_character_video_request_timeout_ms: u64,
@@ -166,7 +170,11 @@ impl Default for AppConfig {
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
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: "wan2.2-t2i-flash".to_string(),
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000, dashscope_image_request_timeout_ms: 150_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(), ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None, ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000, ark_character_video_request_timeout_ms: 420_000,
@@ -397,16 +405,14 @@ impl AppConfig {
if let Some(spacetime_server_url) = read_first_non_empty_env(&[ if let Some(spacetime_server_url) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_SERVER_URL", "GENARRATIVE_SPACETIME_SERVER_URL",
"GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL", "GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL",
]) ]) {
{
config.spacetime_server_url = spacetime_server_url; config.spacetime_server_url = spacetime_server_url;
} }
if let Some(spacetime_database) = read_first_non_empty_env(&[ if let Some(spacetime_database) = read_first_non_empty_env(&[
"GENARRATIVE_SPACETIME_DATABASE", "GENARRATIVE_SPACETIME_DATABASE",
"GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE", "GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE",
]) ]) {
{
config.spacetime_database = spacetime_database; config.spacetime_database = spacetime_database;
} }
@@ -466,12 +472,38 @@ impl AppConfig {
config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]); config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]);
if let Some(dashscope_scene_image_model) =
read_first_non_empty_env(&["DASHSCOPE_SCENE_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_scene_image_model = dashscope_scene_image_model;
}
if let Some(dashscope_reference_image_model) = read_first_non_empty_env(&[
"DASHSCOPE_REFERENCE_IMAGE_MODEL",
"DASHSCOPE_IMAGE_EDIT_MODEL",
]) {
config.dashscope_reference_image_model = dashscope_reference_image_model;
}
if let Some(dashscope_cover_image_model) =
read_first_non_empty_env(&["DASHSCOPE_COVER_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_cover_image_model = dashscope_cover_image_model;
}
if let Some(dashscope_image_request_timeout_ms) = if let Some(dashscope_image_request_timeout_ms) =
read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"]) read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"])
{ {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
} }
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
]) {
config.draft_asset_generation_max_concurrent_requests = max_concurrent_requests;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[ if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL", "ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL", "ARK_BASE_URL",
@@ -625,6 +657,14 @@ fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value))) .find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
} }
fn read_first_usize_env(keys: &[&str]) -> Option<usize> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_usize(&value))
})
}
fn read_first_u8_env(keys: &[&str]) -> Option<u8> { fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
keys.iter() keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value))) .find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
@@ -706,6 +746,15 @@ fn parse_u64(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok() raw.trim().parse::<u64>().ok()
} }
fn parse_positive_usize(raw: &str) -> Option<usize> {
let value = raw.trim().parse::<usize>().ok()?;
if value == 0 {
return None;
}
Some(value)
}
fn parse_u8(raw: &str) -> Option<u8> { fn parse_u8(raw: &str) -> Option<u8> {
raw.trim().parse::<u8>().ok() raw.trim().parse::<u8>().ok()
} }

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use axum::{ use axum::{
Json, Json,
extract::{Extension, Path, State, rejection::JsonRejection}, extract::{Extension, Path, State, rejection::JsonRejection},
@@ -31,14 +33,14 @@ use spacetime_client::{
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError, CustomWorldWorkSummaryRecord, SpacetimeClientError,
}; };
use std::{collections::BTreeSet, convert::Infallible, sync::Arc}; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tracing::info; use tracing::info;
@@ -66,7 +68,6 @@ use crate::{
}; };
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
const DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS: usize = 2;
pub async fn get_custom_world_library( pub async fn get_custom_world_library(
State(state): State<AppState>, State(state): State<AppState>,
@@ -1229,7 +1230,7 @@ fn spawn_custom_world_draft_foundation_job(
}; };
let image_generation_limiter = Arc::new(Semaphore::new( let image_generation_limiter = Arc::new(Semaphore::new(
DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS, state.config.draft_asset_generation_max_concurrent_requests,
)); ));
let role_visual_profile_input = draft_profile_value.clone(); let role_visual_profile_input = draft_profile_value.clone();
let act_background_profile_input = draft_profile_value.clone(); let act_background_profile_input = draft_profile_value.clone();
@@ -1270,7 +1271,9 @@ fn spawn_custom_world_draft_foundation_job(
Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)), Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)),
} }
match act_background_result { match act_background_result {
Ok(profile) => merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile), Ok(profile) => {
merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile)
}
Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)), Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)),
} }
draft_profile_value = draft_profile_with_assets; draft_profile_value = draft_profile_with_assets;
@@ -1465,13 +1468,12 @@ async fn generate_draft_foundation_role_visuals(
) )
.await .await
}; };
match generation_result match generation_result {
{
Ok(generated) => { Ok(generated) => {
return Ok::<_, String>((role_ref.key, role_ref.index, generated)); return Ok::<_, String>((role_ref.key, role_ref.index, generated));
} }
Err(error) => { Err(error) => {
last_error = Some(error.message().to_string()); last_error = Some(error.body_text());
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis( tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt), 300 * u64::from(attempt),
@@ -1531,8 +1533,16 @@ async fn generate_draft_foundation_act_backgrounds(
let world_name = let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = json_text_from_value(draft_profile, "id"); let profile_id = json_text_from_value(draft_profile, "id");
let scene_image_profile_input = draft_profile.clone();
let act_refs = collect_scene_act_refs(draft_profile); let act_refs = collect_scene_act_refs(draft_profile);
validate_scene_act_background_prompts(&act_refs)?; validate_scene_act_background_prompts(&act_refs)?;
tracing::info!(
operation_id,
session_id = %session.session_id,
act_count = act_refs.len(),
max_concurrent_requests = state.config.draft_asset_generation_max_concurrent_requests,
"开始并行生成草稿幕背景图"
);
upsert_custom_world_draft_foundation_progress( upsert_custom_world_draft_foundation_progress(
state, state,
&session.session_id, &session.session_id,
@@ -1553,38 +1563,79 @@ async fn generate_draft_foundation_act_backgrounds(
let task_owner_user_id = owner_user_id.to_string(); let task_owner_user_id = owner_user_id.to_string();
let task_profile_id = profile_id.clone(); let task_profile_id = profile_id.clone();
let task_world_name = world_name.clone(); let task_world_name = world_name.clone();
let task_profile = scene_image_profile_input.clone();
let task_limiter = image_generation_limiter.clone(); let task_limiter = image_generation_limiter.clone();
let task_operation_id = operation_id.to_string();
let task_session_id = session.session_id.clone();
generation_tasks.spawn(async move { generation_tasks.spawn(async move {
let mut last_error = None; let mut last_error = None;
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
let attempt_started_at = Instant::now();
tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
"开始生成单幕背景图"
);
let generation_result = { let generation_result = {
let _permit = task_limiter let _permit = task_limiter.acquire().await.map_err(|error| {
.acquire() (
.await act_ref.chapter_index,
.map_err(|error| format!("图片生成并发控制失效:{error}"))?; act_ref.act_index,
format!("图片生成并发控制失效:{error}"),
)
})?;
generate_custom_world_scene_image_for_profile( generate_custom_world_scene_image_for_profile(
&task_state, &task_state,
task_owner_user_id.as_str(), task_owner_user_id.as_str(),
&task_profile,
task_profile_id.as_deref(), task_profile_id.as_deref(),
task_world_name.as_str(), task_world_name.as_str(),
act_ref.scene_id.as_str(), act_ref.scene_id.as_str(),
act_ref.title.as_str(), act_ref.scene_name.as_str(),
act_ref.summary.as_str(), act_ref.scene_description.as_str(),
act_ref.prompt.as_str(), act_ref.prompt.as_str(),
) )
.await .await
}; };
match generation_result match generation_result {
{
Ok(generated) => { Ok(generated) => {
return Ok::<_, String>(( tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
"单幕背景图生成成功"
);
return Ok::<_, (usize, usize, String)>((
act_ref.chapter_index, act_ref.chapter_index,
act_ref.act_index, act_ref.act_index,
generated, generated,
)); ));
} }
Err(error) => { Err(error) => {
last_error = Some(error.message().to_string()); let error_message = error.body_text();
tracing::warn!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
error_message = %error_message,
"单幕背景图生成失败"
);
last_error = Some(error_message);
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis( tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt), 300 * u64::from(attempt),
@@ -1595,23 +1646,34 @@ async fn generate_draft_foundation_act_backgrounds(
} }
} }
Err(format!( Err((
"{}章第{}幕「{}」背景图连续生成 {} 次失败:{}", act_ref.chapter_index,
act_ref.chapter_index + 1, act_ref.act_index,
act_ref.act_index + 1, format!(
act_ref.title, "{}章第{}幕「{}」背景图连续生成 {} 次失败:{}",
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, act_ref.chapter_index + 1,
last_error.unwrap_or_else(|| "未知错误".to_string()) act_ref.act_index + 1,
act_ref.scene_name,
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
last_error.unwrap_or_else(|| "未知错误".to_string())
),
)) ))
}); });
} }
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut generated_count = 0usize;
while let Some(result) = generation_tasks.join_next().await { while let Some(result) = generation_tasks.join_next().await {
let task_result = result.map_err(|error| error.to_string())?; let task_result = result.map_err(|error| error.to_string())?;
let (chapter_index, act_index, generated) = match task_result { let (chapter_index, act_index, generated) = match task_result {
Ok(value) => value, Ok(value) => value,
Err(message) => { Err((chapter_index, act_index, message)) => {
mark_scene_act_background_generation_error(
draft_profile,
chapter_index,
act_index,
&message,
);
errors.push(message); errors.push(message);
continue; continue;
} }
@@ -1641,14 +1703,48 @@ async fn generate_draft_foundation_act_backgrounds(
"generatedSceneModel".to_string(), "generatedSceneModel".to_string(),
Value::String(generated.model), Value::String(generated.model),
); );
generated_count += 1;
} }
} }
if !errors.is_empty() { if !errors.is_empty() {
if generated_count > 0 {
// 自动草稿生成和手动生成用的是同一套生图与资产入库能力;这里不能因为批量中的个别幕失败,
// 把已经写入 profile 分支的 backgroundImageSrc 一起丢掉,否则前端就看不到已经生成好的图。
tracing::warn!(
generated_count,
failed_count = errors.len(),
error_message = %join_unique_error_messages(errors),
"部分幕背景图生成失败,已保留成功生成的幕图"
);
return Ok(());
}
return Err(join_unique_error_messages(errors)); return Err(join_unique_error_messages(errors));
} }
Ok(()) Ok(())
} }
fn mark_scene_act_background_generation_error(
draft_profile: &mut Value,
chapter_index: usize,
act_index: usize,
message: &str,
) {
if let Some(act_object) = draft_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
.and_then(|chapters| chapters.get_mut(chapter_index))
.and_then(|chapter| chapter.get_mut("acts"))
.and_then(Value::as_array_mut)
.and_then(|acts| acts.get_mut(act_index))
.and_then(Value::as_object_mut)
{
act_object.insert(
"backgroundGenerationError".to_string(),
Value::String(message.trim().to_string()),
);
}
}
fn join_unique_error_messages(messages: Vec<String>) -> String { fn join_unique_error_messages(messages: Vec<String>) -> String {
// 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。 // 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。
messages messages
@@ -1673,12 +1769,13 @@ struct SceneActGenerationRef {
chapter_index: usize, chapter_index: usize,
act_index: usize, act_index: usize,
scene_id: String, scene_id: String,
title: String, scene_name: String,
summary: String, scene_description: String,
prompt: String, prompt: String,
} }
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> { fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
draft_profile draft_profile
.get("sceneChapterBlueprints") .get("sceneChapterBlueprints")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -1689,21 +1786,31 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let chapter_scene_id = json_text_from_value(chapter, "sceneId") let chapter_scene_id = json_text_from_value(chapter, "sceneId")
.or_else(|| json_text_from_value(chapter, "id")) .or_else(|| json_text_from_value(chapter, "id"))
.unwrap_or_else(|| format!("chapter-{chapter_index}")); .unwrap_or_else(|| format!("chapter-{chapter_index}"));
let chapter_scene_name = json_first_text_from_value(
chapter,
&["sceneName", "landmarkName", "name", "title"],
)
.unwrap_or_else(|| chapter_scene_id.clone());
let chapter_scene_context = scene_context_by_id
.get(&chapter_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: chapter_scene_id.clone(),
name: chapter_scene_name.clone(),
description: json_text_from_value(chapter, "description")
.or_else(|| json_text_from_value(chapter, "summary"))
.unwrap_or_default(),
danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(),
});
let scene_contexts = scene_context_by_id.clone();
chapter chapter
.get("acts") .get("acts")
.and_then(Value::as_array) .and_then(Value::as_array)
.into_iter() .into_iter()
.flatten() .flatten()
.enumerate() .enumerate()
.map(move |(act_index, act)| SceneActGenerationRef { .map(move |(act_index, act)| {
chapter_index, let prompt = json_first_text_from_value(
act_index,
scene_id: json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_id.clone()),
title: json_text_from_value(act, "title")
.unwrap_or_else(|| format!("{}", act_index + 1)),
summary: json_text_from_value(act, "summary").unwrap_or_default(),
prompt: json_first_text_from_value(
act, act,
&[ &[
"backgroundPromptText", "backgroundPromptText",
@@ -1715,19 +1822,90 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
"visualPrompt", "visualPrompt",
], ],
) )
.unwrap_or_default(), .unwrap_or_default();
let scene_name = json_first_text_from_value(
act,
&["sceneName", "landmarkName", "locationName"],
)
.unwrap_or_else(|| chapter_scene_context.name.clone());
let act_scene_id = json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_context.id.clone());
let scene_context =
scene_contexts
.get(&act_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: act_scene_id.clone(),
name: scene_name,
description: chapter_scene_context.description.clone(),
danger_level: chapter_scene_context.danger_level.clone(),
});
SceneActGenerationRef {
chapter_index,
act_index,
scene_id: act_scene_id,
scene_name: scene_context.name,
scene_description: scene_context.description,
prompt: prompt.clone(),
}
}) })
}) })
.collect() .collect()
} }
#[derive(Clone, Debug)]
struct SceneImageContext {
id: String,
name: String,
description: String,
danger_level: String,
}
fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap<String, SceneImageContext> {
let mut contexts = BTreeMap::new();
if let Some(camp) = draft_profile.get("camp").and_then(Value::as_object) {
if let Some(context) = scene_context_from_object(camp, "camp") {
contexts.insert(context.id.clone(), context);
}
}
if let Some(landmarks) = draft_profile.get("landmarks").and_then(Value::as_array) {
for landmark in landmarks.iter().filter_map(Value::as_object) {
if let Some(context) = scene_context_from_object(landmark, "landmark") {
contexts.insert(context.id.clone(), context);
}
}
}
contexts
}
fn scene_context_from_object(
object: &Map<String, Value>,
fallback_id: &str,
) -> Option<SceneImageContext> {
let id = read_string_field(object, "id")
.or_else(|| read_string_field(object, "sceneId"))
.unwrap_or_else(|| fallback_id.to_string());
let name = read_string_field(object, "name")
.or_else(|| read_string_field(object, "sceneName"))
.unwrap_or_else(|| id.clone());
Some(SceneImageContext {
id,
name,
description: read_string_field(object, "description")
.or_else(|| read_string_field(object, "visualDescription"))
.unwrap_or_default(),
danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(),
})
}
fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> { fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> {
if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) { if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) {
return Err(format!( return Err(format!(
"{}章第{}幕「{}」缺少 backgroundPromptText不能在幕背景图描述文本生成前直接生图。", "{}章第{}幕「{}」缺少 backgroundPromptText不能在幕背景图描述文本生成前直接生图。",
act_ref.chapter_index + 1, act_ref.chapter_index + 1,
act_ref.act_index + 1, act_ref.act_index + 1,
act_ref.title act_ref.scene_name
)); ));
} }
@@ -2480,13 +2658,28 @@ mod tests {
#[test] #[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() { fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({ let draft_profile = json!({
"name": "雾港纪元",
"tone": "潮湿、悬疑、低照度",
"landmarks": [
{
"id": "scene-office",
"name": "旧港办公室",
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。",
"dangerLevel": "low"
}
],
"sceneChapterBlueprints": [ "sceneChapterBlueprints": [
{ {
"sceneId": "scene-office", "sceneId": "scene-office",
"sceneName": "旧港办公室",
"acts": [ "acts": [
{ {
"title": "深夜工位", "title": "深夜工位",
"summary": "团队在凌晨三点继续赶版本。", "summary": "团队在凌晨三点继续赶版本。",
"actGoal": "找到丢失的部署钥匙",
"transitionHook": "电梯门在无人操作时打开",
"primaryRoleName": "林澈",
"supportRoleNames": ["阿岚"],
"scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌" "scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌"
} }
] ]
@@ -2498,6 +2691,12 @@ mod tests {
assert_eq!(act_refs.len(), 1); assert_eq!(act_refs.len(), 1);
assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌"); assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌");
assert_eq!(act_refs[0].scene_id, "scene-office");
assert_eq!(act_refs[0].scene_name, "旧港办公室");
assert_eq!(
act_refs[0].scene_description,
"旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
);
assert!(validate_scene_act_background_prompts(&act_refs).is_ok()); assert!(validate_scene_act_background_prompts(&act_refs).is_ok());
} }
} }

View File

@@ -323,7 +323,6 @@ struct NormalizedSceneImageRequest {
prompt: String, prompt: String,
negative_prompt: String, negative_prompt: String,
reference_image_src: Option<String>, reference_image_src: Option<String>,
model: String,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -341,10 +340,6 @@ struct OptimizedCoverUpload {
bytes: Vec<u8>, bytes: Vec<u8>,
} }
const TEXT_TO_IMAGE_SCENE_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_SCENE_MODEL: &str = "qwen-image-2.0";
const TEXT_TO_IMAGE_COVER_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_COVER_MODEL: &str = "qwen-image-2.0";
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头"; const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
const COVER_OUTPUT_WIDTH: u32 = 1600; const COVER_OUTPUT_WIDTH: u32 = 1600;
const COVER_OUTPUT_HEIGHT: u32 = 900; const COVER_OUTPUT_HEIGHT: u32 = 900;
@@ -467,7 +462,7 @@ pub async fn generate_custom_world_scene_image(
create_reference_image_generation( create_reference_image_generation(
&http_client, &http_client,
&settings, &settings,
REFERENCE_IMAGE_SCENE_MODEL, state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(), normalized.prompt.as_str(),
normalized.size.as_str(), normalized.size.as_str(),
&[reference_image.to_string()], &[reference_image.to_string()],
@@ -481,7 +476,7 @@ pub async fn generate_custom_world_scene_image(
create_text_to_image_generation( create_text_to_image_generation(
&http_client, &http_client,
&settings, &settings,
TEXT_TO_IMAGE_SCENE_MODEL, state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(), normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()), Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(), normalized.size.as_str(),
@@ -493,6 +488,11 @@ pub async fn generate_custom_world_scene_image(
.await .await
} }
.map_err(|error| custom_world_ai_error_response(&request_context, error))?; .map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let downloaded = download_remote_image( let downloaded = download_remote_image(
&http_client, &http_client,
generated.image_url.as_str(), generated.image_url.as_str(),
@@ -532,7 +532,7 @@ pub async fn generate_custom_world_scene_image(
image_src: String::new(), image_src: String::new(),
asset_id: asset_id.clone(), asset_id: asset_id.clone(),
source_type: "generated".to_string(), source_type: "generated".to_string(),
model: Some(normalized.model), model: Some(scene_model),
size: Some(normalized.size), size: Some(normalized.size),
task_id: Some(generated.task_id), task_id: Some(generated.task_id),
prompt: Some(normalized.prompt), prompt: Some(normalized.prompt),
@@ -548,6 +548,7 @@ pub async fn generate_custom_world_scene_image(
pub(crate) async fn generate_custom_world_scene_image_for_profile( pub(crate) async fn generate_custom_world_scene_image_for_profile(
state: &AppState, state: &AppState,
owner_user_id: &str, owner_user_id: &str,
profile: &Value,
profile_id: Option<&str>, profile_id: Option<&str>,
world_name: &str, world_name: &str,
scene_id: &str, scene_id: &str,
@@ -560,20 +561,16 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
world_name: Some(world_name.to_string()), world_name: Some(world_name.to_string()),
landmark_id: Some(scene_id.to_string()), landmark_id: Some(scene_id.to_string()),
landmark_name: Some(scene_name.to_string()), landmark_name: Some(scene_name.to_string()),
prompt: Some(prompt_text.to_string()), // 自动草稿生成必须和草稿页手动生成走同一条 prompt 编译链:
size: Some("1600*900".to_string()), // 只把幕级描述作为 userPrompt 输入,仍交给 normalize_scene_image_request 组装世界名、地点名、风格与负面词。
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None, negative_prompt: None,
reference_image_src: None, reference_image_src: None,
user_prompt: Some(prompt_text.to_string()), user_prompt: Some(prompt_text.to_string()),
profile: Some(SceneImageProfileInput { profile: Some(scene_image_profile_input_from_value(
id: profile_id.map(ToOwned::to_owned), profile, profile_id, world_name,
name: Some(world_name.to_string()), )),
subtitle: None,
summary: None,
tone: None,
player_goal: None,
setting_text: None,
}),
landmark: Some(SceneImageLandmarkInput { landmark: Some(SceneImageLandmarkInput {
id: Some(scene_id.to_string()), id: Some(scene_id.to_string()),
name: Some(scene_name.to_string()), name: Some(scene_name.to_string()),
@@ -587,7 +584,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
let generated = create_text_to_image_generation( let generated = create_text_to_image_generation(
&http_client, &http_client,
&settings, &settings,
TEXT_TO_IMAGE_SCENE_MODEL, state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(), normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()), Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(), normalized.size.as_str(),
@@ -627,7 +624,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
slot: "scene_image", slot: "scene_image",
source_job_id: Some(generated.task_id.clone()), source_job_id: Some(generated.task_id.clone()),
}; };
let model = normalized.model.clone(); let model = state.config.dashscope_scene_image_model.clone();
let prompt = normalized.prompt.clone(); let prompt = normalized.prompt.clone();
let asset = persist_custom_world_asset( let asset = persist_custom_world_asset(
state, state,
@@ -653,6 +650,31 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
}) })
} }
fn scene_image_profile_input_from_value(
profile: &Value,
profile_id: Option<&str>,
world_name: &str,
) -> SceneImageProfileInput {
SceneImageProfileInput {
id: profile_id.map(ToOwned::to_owned),
name: Some(world_name.to_string()),
subtitle: json_text_from_value(profile, "subtitle"),
summary: json_text_from_value(profile, "summary"),
tone: json_text_from_value(profile, "tone"),
player_goal: json_text_from_value(profile, "playerGoal"),
setting_text: json_text_from_value(profile, "settingText"),
}
}
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub async fn generate_custom_world_cover_image( pub async fn generate_custom_world_cover_image(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -707,7 +729,7 @@ pub async fn generate_custom_world_cover_image(
create_text_to_image_generation( create_text_to_image_generation(
&http_client, &http_client,
&settings, &settings,
TEXT_TO_IMAGE_COVER_MODEL, state.config.dashscope_cover_image_model.as_str(),
prompt.as_str(), prompt.as_str(),
None, None,
size.as_str(), size.as_str(),
@@ -721,7 +743,7 @@ pub async fn generate_custom_world_cover_image(
create_reference_image_generation( create_reference_image_generation(
&http_client, &http_client,
&settings, &settings,
REFERENCE_IMAGE_COVER_MODEL, state.config.dashscope_reference_image_model.as_str(),
prompt.as_str(), prompt.as_str(),
size.as_str(), size.as_str(),
&reference_images, &reference_images,
@@ -766,9 +788,9 @@ pub async fn generate_custom_world_cover_image(
asset_id: asset_id.clone(), asset_id: asset_id.clone(),
source_type: "generated".to_string(), source_type: "generated".to_string(),
model: Some(if reference_images.is_empty() { model: Some(if reference_images.is_empty() {
TEXT_TO_IMAGE_COVER_MODEL.to_string() state.config.dashscope_cover_image_model.clone()
} else { } else {
REFERENCE_IMAGE_COVER_MODEL.to_string() state.config.dashscope_reference_image_model.clone()
}), }),
size: Some(size), size: Some(size),
task_id: Some(generated.task_id), task_id: Some(generated.task_id),
@@ -1187,11 +1209,6 @@ fn normalize_scene_image_request(
negative_prompt: trim_to_option(payload.negative_prompt.as_deref()) negative_prompt: trim_to_option(payload.negative_prompt.as_deref())
.unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()), .unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()),
reference_image_src: reference_image_src.clone(), reference_image_src: reference_image_src.clone(),
model: if reference_image_src.is_some() {
REFERENCE_IMAGE_SCENE_MODEL.to_string()
} else {
TEXT_TO_IMAGE_SCENE_MODEL.to_string()
},
}) })
} }
@@ -2580,6 +2597,103 @@ mod tests {
); );
} }
#[test]
fn automatic_scene_image_payload_reuses_manual_prompt_compiler() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let payload = CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some("破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。".to_string()),
profile: Some(scene_image_profile_input_from_value(
&profile,
Some("profile_001"),
"雾海群岛",
)),
landmark: Some(SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: None,
}),
};
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
assert!(normalized.prompt.contains("世界名:雾海群岛"));
assert!(normalized.prompt.contains("世界副标题:失落航线"));
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
assert!(
normalized
.prompt
.contains("本次想要生成的画面内容:破碎神殿")
);
assert_ne!(
normalized.prompt,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
);
}
#[test]
fn automatic_default_scene_image_context_matches_manual_default_context() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let user_prompt = "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。";
let profile_input =
scene_image_profile_input_from_value(&profile, Some("profile_001"), "雾海群岛");
let landmark = SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: Some("high".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
&profile_input,
&landmark,
user_prompt,
false,
Some("礁石神殿"),
"雾海群岛",
);
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(user_prompt.to_string()),
profile: Some(profile_input),
landmark: Some(landmark),
})
.expect("payload should normalize");
assert_eq!(normalized.prompt, manual_prompt);
}
#[tokio::test] #[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() { async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build"); let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -38,6 +38,18 @@ impl AppError {
&self.message &self.message
} }
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details
.as_ref()
.and_then(|details| details.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.unwrap_or(self.message.as_str())
.to_string()
}
pub fn with_message(mut self, message: impl Into<String>) -> Self { pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into(); self.message = message.into();
self self

View File

@@ -1518,13 +1518,17 @@ struct GeneratedPuzzleAssetResponse {
asset_id: String, asset_id: String,
} }
fn require_puzzle_dashscope_settings(state: &AppState) -> Result<PuzzleDashScopeSettings, AppError> { fn require_puzzle_dashscope_settings(
state: &AppState,
) -> Result<PuzzleDashScopeSettings, AppError> {
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() { if base_url.is_empty() {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ return Err(
"provider": "dashscope", AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"reason": "DASHSCOPE_BASE_URL 未配置", "provider": "dashscope",
}))); "reason": "DASHSCOPE_BASE_URL 未配置",
})),
);
} }
let api_key = state let api_key = state
@@ -1613,7 +1617,9 @@ async fn create_puzzle_text_to_image_generation(
})) }))
.send() .send()
.await .await
.map_err(|error| map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")))?; .map_err(|error| {
map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}"))
})?;
let status = response.status(); let status = response.status();
let response_text = response.text().await.map_err(|error| { let response_text = response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}")) map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}"))
@@ -1655,7 +1661,8 @@ async fn create_puzzle_text_to_image_generation(
"查询拼图图片生成任务失败", "查询拼图图片生成任务失败",
)); ));
} }
let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; let poll_payload =
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
@@ -1663,13 +1670,18 @@ async fn create_puzzle_text_to_image_generation(
if task_status == "SUCCEEDED" { if task_status == "SUCCEEDED" {
let image_urls = extract_puzzle_image_urls(&poll_payload); let image_urls = extract_puzzle_image_urls(&poll_payload);
if image_urls.is_empty() { if image_urls.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ return Err(
"provider": "dashscope", AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"message": "拼图图片生成成功但未返回图片地址", "provider": "dashscope",
}))); "message": "拼图图片生成成功但未返回图片地址",
})),
);
} }
let mut images = Vec::with_capacity(image_urls.len()); let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls.into_iter().take(candidate_count.clamp(1, 2) as usize) { for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 2) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
} }
return Ok(PuzzleGeneratedImages { task_id, images }); return Ok(PuzzleGeneratedImages { task_id, images });
@@ -1683,10 +1695,12 @@ async fn create_puzzle_text_to_image_generation(
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
} }
Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ Err(
"provider": "dashscope", AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"message": "拼图图片生成超时或未返回图片地址", "provider": "dashscope",
}))) "message": "拼图图片生成超时或未返回图片地址",
})),
)
} }
async fn download_puzzle_remote_image( async fn download_puzzle_remote_image(
@@ -1707,11 +1721,13 @@ async fn download_puzzle_remote_image(
map_puzzle_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}")) map_puzzle_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}"))
})?; })?;
if !status.is_success() { if !status.is_success() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ return Err(
"provider": "dashscope", AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"message": "下载拼图正式图片失败", "provider": "dashscope",
"status": status.as_u16(), "message": "下载拼图正式图片失败",
}))); "status": status.as_u16(),
})),
);
} }
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
Ok(PuzzleDownloadedImage { Ok(PuzzleDownloadedImage {

View File

@@ -1,4 +1,12 @@
use std::{error::Error, fmt, str as std_str, time::Duration}; use std::{
env,
error::Error,
fmt, fs,
path::PathBuf,
str as std_str,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use log::{debug, warn}; use log::{debug, warn};
use reqwest::{Client, StatusCode}; use reqwest::{Client, StatusCode};
@@ -10,6 +18,9 @@ pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
pub const DEFAULT_MAX_RETRIES: u32 = 1; pub const DEFAULT_MAX_RETRIES: u32 = 1;
pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500; pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500;
pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw";
static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1);
// 冻结平台来源,避免上层继续散落 provider 字符串。 // 冻结平台来源,避免上层继续散落 provider 字符串。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -113,6 +124,17 @@ struct ChatCompletionsRequestBody<'a> {
max_tokens: Option<u32>, max_tokens: Option<u32>,
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmRawFailureInputLog<'a> {
provider: &'static str,
model: &'a str,
stream: bool,
attempt: u32,
max_tokens: Option<u32>,
messages: &'a [LlmMessage],
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ChatCompletionsResponseEnvelope { struct ChatCompletionsResponseEnvelope {
id: Option<String>, id: Option<String>,
@@ -156,6 +178,7 @@ struct ChatCompletionsContentPart {
#[derive(Default)] #[derive(Default)]
struct OpenAiCompatibleSseParser { struct OpenAiCompatibleSseParser {
buffer: String, buffer: String,
raw_text: String,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -382,12 +405,31 @@ impl LlmClient {
request.validate()?; request.validate()?;
let resolved_model = request.resolved_model(self.config.model()).to_string(); let resolved_model = request.resolved_model(self.config.model()).to_string();
let response = self.execute_request(&request, false).await?; let response = self.execute_request(&request, false).await?;
let raw_text = response let raw_text = response.text().await.map_err(|error| {
.text() let llm_error = map_stream_read_error(error, 1);
.await log_llm_raw_failure(
.map_err(|error| map_stream_read_error(error, 1))?; &self.config,
&request,
false,
1,
"read_response_failed",
llm_error.to_string().as_str(),
);
llm_error
})?;
parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str()) parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str())
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
false,
1,
"parse_response_failed",
raw_text.as_str(),
);
error
})
} }
pub async fn request_single_message_text( pub async fn request_single_message_text(
@@ -422,10 +464,18 @@ impl LlmClient {
let mut undecoded_chunk_bytes = Vec::new(); let mut undecoded_chunk_bytes = Vec::new();
loop { loop {
let next_chunk = response let next_chunk = response.chunk().await.map_err(|error| {
.chunk() let llm_error = map_stream_read_error(error, 1);
.await log_llm_raw_failure(
.map_err(|error| map_stream_read_error(error, 1))?; &self.config,
&request,
true,
1,
"read_stream_failed",
parser.raw_text().as_str(),
);
llm_error
})?;
let Some(chunk) = next_chunk else { let Some(chunk) = next_chunk else {
break; break;
@@ -433,12 +483,33 @@ impl LlmClient {
undecoded_chunk_bytes.extend_from_slice(chunk.as_ref()); undecoded_chunk_bytes.extend_from_slice(chunk.as_ref());
let (chunk_text, remaining_bytes) = let (chunk_text, remaining_bytes) =
decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice())?; decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"decode_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
undecoded_chunk_bytes = remaining_bytes; undecoded_chunk_bytes = remaining_bytes;
if chunk_text.is_empty() { if chunk_text.is_empty() {
continue; continue;
} }
for event in parser.push_chunk(chunk_text.as_ref())? { let stream_events = parser.push_chunk(chunk_text.as_ref()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in stream_events {
if let Some(delta_text) = event.delta_text if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty() && !delta_text.is_empty()
{ {
@@ -460,10 +531,29 @@ impl LlmClient {
if !undecoded_chunk_bytes.is_empty() { if !undecoded_chunk_bytes.is_empty() {
let trailing_text = let trailing_text =
std_str::from_utf8(undecoded_chunk_bytes.as_slice()).map_err(|error| { std_str::from_utf8(undecoded_chunk_bytes.as_slice()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"decode_stream_failed",
parser.raw_text().as_str(),
);
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}")) LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}"))
})?; })?;
if !trailing_text.is_empty() { if !trailing_text.is_empty() {
for event in parser.push_chunk(trailing_text)? { let trailing_events = parser.push_chunk(trailing_text).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in trailing_events {
if let Some(delta_text) = event.delta_text if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty() && !delta_text.is_empty()
{ {
@@ -483,7 +573,18 @@ impl LlmClient {
} }
} }
for event in parser.finish()? { let remaining_events = parser.finish().map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in remaining_events {
if let Some(delta_text) = event.delta_text if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty() && !delta_text.is_empty()
{ {
@@ -503,6 +604,14 @@ impl LlmClient {
let content = accumulated_text.trim().to_string(); let content = accumulated_text.trim().to_string();
if content.is_empty() { if content.is_empty() {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"empty_stream_response",
parser.raw_text().as_str(),
);
return Err(LlmError::EmptyResponse); return Err(LlmError::EmptyResponse);
} }
@@ -591,6 +700,14 @@ impl LlmClient {
continue; continue;
} }
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"upstream_status_failed",
raw_text.as_str(),
);
return Err(LlmError::Upstream { return Err(LlmError::Upstream {
status_code: status.as_u16(), status_code: status.as_u16(),
message, message,
@@ -607,7 +724,16 @@ impl LlmClient {
continue; continue;
} }
return Err(LlmError::Timeout { attempts: attempt }); let error = LlmError::Timeout { attempts: attempt };
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_timeout",
error.to_string().as_str(),
);
return Err(error);
} }
Err(error) if error.is_connect() => { Err(error) if error.is_connect() => {
let message = error.to_string(); let message = error.to_string();
@@ -622,13 +748,31 @@ impl LlmClient {
continue; continue;
} }
return Err(LlmError::Connectivity { let error = LlmError::Connectivity {
attempts: attempt, attempts: attempt,
message, message,
}); };
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_connectivity_failed",
error.to_string().as_str(),
);
return Err(error);
} }
Err(error) => { Err(error) => {
return Err(LlmError::Transport(error.to_string())); let error = LlmError::Transport(error.to_string());
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_transport_failed",
error.to_string().as_str(),
);
return Err(error);
} }
} }
} }
@@ -652,11 +796,16 @@ impl LlmClient {
impl OpenAiCompatibleSseParser { impl OpenAiCompatibleSseParser {
fn push_chunk(&mut self, chunk: &str) -> Result<Vec<ParsedStreamEvent>, LlmError> { fn push_chunk(&mut self, chunk: &str) -> Result<Vec<ParsedStreamEvent>, LlmError> {
self.raw_text.push_str(chunk);
self.buffer.push_str(chunk); self.buffer.push_str(chunk);
self.buffer = self.buffer.replace("\r\n", "\n"); self.buffer = self.buffer.replace("\r\n", "\n");
self.drain_complete_events() self.drain_complete_events()
} }
fn raw_text(&self) -> String {
self.raw_text.clone()
}
fn finish(&mut self) -> Result<Vec<ParsedStreamEvent>, LlmError> { fn finish(&mut self) -> Result<Vec<ParsedStreamEvent>, LlmError> {
if self.buffer.trim().is_empty() { if self.buffer.trim().is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -691,6 +840,87 @@ fn normalize_non_empty(value: String, error_message: &str) -> Result<String, Llm
Ok(trimmed) Ok(trimmed)
} }
fn log_llm_raw_failure(
config: &LlmConfig,
request: &LlmTextRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) {
if let Err(error) =
write_llm_raw_failure(config, request, stream, attempt, failure_stage, raw_output)
{
warn!(
"LLM 失败原文日志落盘失败,主错误流程继续执行: failure_stage={}, error={}",
failure_stage, error
);
}
}
fn write_llm_raw_failure(
config: &LlmConfig,
request: &LlmTextRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) -> Result<(), String> {
let log_dir = env::var("LLM_RAW_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR));
fs::create_dir_all(&log_dir).map_err(|error| format!("创建日志目录失败:{error}"))?;
let prefix = build_llm_raw_log_prefix(failure_stage);
let model = request.resolved_model(config.model());
let input_log = LlmRawFailureInputLog {
provider: config.provider().as_str(),
model,
stream,
attempt,
max_tokens: request.max_tokens,
messages: request.messages.as_slice(),
};
let input_text = serde_json::to_string_pretty(&input_log)
.map_err(|error| format!("序列化模型输入日志失败:{error}"))?;
fs::write(log_dir.join(format!("{prefix}.input.json")), input_text)
.map_err(|error| format!("写入模型输入日志失败:{error}"))?;
fs::write(log_dir.join(format!("{prefix}.output.txt")), raw_output)
.map_err(|error| format!("写入模型输出日志失败:{error}"))?;
Ok(())
}
fn build_llm_raw_log_prefix(failure_stage: &str) -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = LLM_RAW_LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let safe_stage = sanitize_log_file_segment(failure_stage);
format!("{millis}-{}-{sequence:06}-{safe_stage}", std::process::id())
}
fn sanitize_log_file_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn parse_chat_completions_response( fn parse_chat_completions_response(
provider: LlmProvider, provider: LlmProvider,
fallback_model: &str, fallback_model: &str,
@@ -1028,6 +1258,62 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("req_stream_01")); assert_eq!(response.response_id.as_deref(), Some("req_stream_01"));
} }
#[tokio::test]
async fn request_text_writes_raw_failure_logs_after_parse_error() {
let log_dir = std::env::temp_dir().join(format!(
"platform-llm-raw-log-test-{}",
build_llm_raw_log_prefix("parse_error")
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: "不是合法 JSON".to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.request_single_message_text("系统原文", "用户原文")
.await
.expect_err("invalid json should fail");
assert!(matches!(error, LlmError::Deserialize(_)));
let mut input_logs = Vec::new();
let mut output_logs = Vec::new();
for entry in fs::read_dir(&log_dir).expect("log dir should exist") {
let path = entry.expect("log entry should be readable").path();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string();
if file_name.ends_with(".input.json") {
input_logs.push(path);
} else if file_name.ends_with(".output.txt") {
output_logs.push(path);
}
}
assert_eq!(input_logs.len(), 1);
assert_eq!(output_logs.len(), 1);
let input_text = fs::read_to_string(&input_logs[0]).expect("input log should be readable");
let output_text =
fs::read_to_string(&output_logs[0]).expect("output log should be readable");
assert!(input_text.contains("系统原文"));
assert!(input_text.contains("用户原文"));
assert!(!input_text.contains("test-key"));
assert_eq!(output_text, "不是合法 JSON");
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
fs::remove_dir_all(log_dir).expect("log dir should be removed");
}
fn build_test_client(base_url: String, max_retries: u32) -> LlmClient { fn build_test_client(base_url: String, max_retries: u32) -> LlmClient {
let config = LlmConfig::new( let config = LlmConfig::new(
LlmProvider::Ark, LlmProvider::Ark,

View File

@@ -520,38 +520,4 @@ impl SpacetimeClient {
.await .await
} }
pub async fn upsert_custom_world_agent_operation_progress(
&self,
input: CustomWorldAgentOperationProgressRecordInput,
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
let procedure_input = CustomWorldAgentOperationProgressInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
operation_id: input.operation_id,
operation_type: parse_rpg_agent_operation_type_record(input.operation_type.as_str())?,
operation_status: parse_rpg_agent_operation_status_record(
input.operation_status.as_str(),
)?,
phase_label: input.phase_label,
phase_detail: input.phase_detail,
operation_progress: input.operation_progress,
error_message: input.error_message,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.upsert_custom_world_agent_operation_progress_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
} }

View File

@@ -2762,41 +2762,6 @@ pub(crate) fn format_rpg_agent_operation_status(
} }
} }
pub(crate) fn parse_rpg_agent_operation_type_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationType, SpacetimeClientError> {
match value.trim() {
"process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage),
"draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => {
Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile)
}
"generate_characters" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters)
}
"generate_landmarks" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks)
}
"generate_role_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets)
}
"sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets)
}
"sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail),
"publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld),
"revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint),
"delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters),
"delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks),
other => Err(SpacetimeClientError::Runtime(format!(
"未知 rpg agent operation type: {other}"
))),
}
}
pub(crate) fn parse_rpg_agent_operation_status_record( pub(crate) fn parse_rpg_agent_operation_status_record(
value: &str, value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationStatus, SpacetimeClientError> { ) -> Result<crate::module_bindings::RpgAgentOperationStatus, SpacetimeClientError> {
@@ -3745,20 +3710,6 @@ pub struct CustomWorldAgentMessageFinalizeRecordInput {
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentOperationProgressRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub operation_type: String,
pub operation_status: String,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentActionExecuteRecordInput { pub struct CustomWorldAgentActionExecuteRecordInput {
pub session_id: String, pub session_id: String,

View File

@@ -117,7 +117,6 @@ pub mod custom_world_agent_operation_type;
pub mod custom_world_agent_operation_get_input_type; pub mod custom_world_agent_operation_get_input_type;
pub mod custom_world_agent_operation_progress_input_type; pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_procedure_result_type; pub mod custom_world_agent_operation_procedure_result_type;
pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_snapshot_type; pub mod custom_world_agent_operation_snapshot_type;
pub mod custom_world_agent_session_type; pub mod custom_world_agent_session_type;
pub mod custom_world_agent_session_create_input_type; pub mod custom_world_agent_session_create_input_type;
@@ -343,7 +342,6 @@ pub mod start_ai_task_reducer;
pub mod start_ai_task_stage_reducer; pub mod start_ai_task_stage_reducer;
pub mod turn_in_quest_reducer; pub mod turn_in_quest_reducer;
pub mod unpublish_custom_world_profile_reducer; pub mod unpublish_custom_world_profile_reducer;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_chapter_progression_reducer; pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_profile_reducer; pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer; pub mod upsert_npc_state_reducer;
@@ -591,7 +589,6 @@ pub use custom_world_agent_operation_type::CustomWorldAgentOperation;
pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput; pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot;
pub use custom_world_agent_session_type::CustomWorldAgentSession; pub use custom_world_agent_session_type::CustomWorldAgentSession;
pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput;
@@ -859,7 +856,6 @@ pub use start_ai_task_reducer::start_ai_task;
pub use start_ai_task_stage_reducer::start_ai_task_stage; pub use start_ai_task_stage_reducer::start_ai_task_stage;
pub use turn_in_quest_reducer::turn_in_quest; pub use turn_in_quest_reducer::turn_in_quest;
pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
pub use upsert_npc_state_reducer::upsert_npc_state; pub use upsert_npc_state_reducer::upsert_npc_state;

View File

@@ -1470,7 +1470,7 @@ test('big fish draft card restores the bound agent session and opens the result
throw new Error('Missing big fish draft card'); throw new Error('Missing big fish draft card');
} }
await user.click(within(card).getByRole('button', { name: //u })); await user.click(card);
await waitFor(() => { await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith( expect(getBigFishCreationSession).toHaveBeenCalledWith(
@@ -1522,6 +1522,70 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull(); expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
}); });
test('refresh restores running draft generation progress instead of agent workspace', async () => {
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-1&customWorldOperationId=operation-draft-foundation-1&customWorldGenerationSource=agent-draft-foundation',
);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
});
render(<TestWrapper withAuth />);
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '失败中的潮雾列岛',
subtitle: '生成失败待处理',
summary: '草稿生成过程中失败,需要继续处理。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '生成失败待处理',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
});
test('existing draft sessions open result page refinement instead of agent dialog', async () => { test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -22,6 +22,7 @@ type UseRpgCreationAgentOperationPollingParams = {
persistAgentUiState: ( persistAgentUiState: (
sessionId: string | null, sessionId: string | null,
operationId: string | null, operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void; ) => void;
syncAgentSessionSnapshot: ( syncAgentSessionSnapshot: (
sessionId: string, sessionId: string,
@@ -68,7 +69,15 @@ export function useRpgCreationAgentOperationPolling(
nextOperation.status === 'completed' || nextOperation.status === 'completed' ||
nextOperation.status === 'failed' nextOperation.status === 'failed'
) { ) {
persistAgentUiState(activeAgentSessionId, null); persistAgentUiState(
activeAgentSessionId,
nextOperation.type === 'draft_foundation'
? activeAgentOperationId
: null,
nextOperation.type === 'draft_foundation'
? 'agent-draft-foundation'
: null,
);
await syncAgentSessionSnapshot(activeAgentSessionId).catch( await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null, () => null,
); );

View File

@@ -50,6 +50,7 @@ type UseRpgCreationResultAutosaveParams = {
persistAgentUiState: ( persistAgentUiState: (
sessionId: string | null, sessionId: string | null,
operationId: string | null, operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void; ) => void;
syncAgentSessionSnapshot: ( syncAgentSessionSnapshot: (
sessionId: string, sessionId: string,

View File

@@ -51,6 +51,9 @@ type PendingAgentUserMessage = {
message: CustomWorldAgentSessionSnapshot['messages'][number]; message: CustomWorldAgentSessionSnapshot['messages'][number];
}; };
const AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS = 12;
const AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS = 900;
export function useRpgCreationSessionController( export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams, params: UseRpgCreationSessionControllerParams,
) { ) {
@@ -162,12 +165,17 @@ export function useRpgCreationSessionController(
); );
const persistAgentUiState = useCallback( const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => { (
nextSessionId: string | null,
nextOperationId: string | null,
nextGenerationSource: CustomWorldGenerationViewSource = null,
) => {
setActiveAgentSessionId(nextSessionId); setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId); setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({ writeCustomWorldAgentUiState({
activeSessionId: nextSessionId, activeSessionId: nextSessionId,
activeOperationId: nextOperationId, activeOperationId: nextOperationId,
customWorldGenerationSource: nextGenerationSource,
// 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户, // 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户,
// 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。 // 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。
ownerUserId: nextSessionId ? userId : null, ownerUserId: nextSessionId ? userId : null,
@@ -211,6 +219,16 @@ export function useRpgCreationSessionController(
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) { if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true; hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => { openLoginModal?.(() => {
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace'); setSelectionStage('agent-workspace');
}); });
} }
@@ -228,6 +246,17 @@ export function useRpgCreationSessionController(
} }
hasAppliedInitialAgentWorkspaceRef.current = true; hasAppliedInitialAgentWorkspaceRef.current = true;
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace'); setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]); }, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
@@ -365,8 +394,23 @@ export function useRpgCreationSessionController(
} }
let cancelled = false; let cancelled = false;
const timeoutId = window.setTimeout(() => { void (async () => {
void (async () => { for (
let attempt = 1;
attempt <= AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS;
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch( ? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null, () => null,
@@ -382,10 +426,7 @@ export function useRpgCreationSessionController(
latestSession ?? agentSession, latestSession ?? agentSession,
); );
if (!draftResultProfile) { if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null); continue;
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
} }
setGeneratedCustomWorldProfile( setGeneratedCustomWorldProfile(
@@ -395,12 +436,16 @@ export function useRpgCreationSessionController(
setCustomWorldGenerationViewSource(null); setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft'); setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result'); setSelectionStage('custom-world-result');
})(); return;
}, 900); }
if (!cancelled) {
setAgentDraftGenerationStartedAt(null);
}
})();
return () => { return () => {
cancelled = true; cancelled = true;
window.clearTimeout(timeoutId);
}; };
}, [ }, [
activeAgentSessionId, activeAgentSessionId,
@@ -678,7 +723,11 @@ export function useRpgCreationSessionController(
payload, payload,
); );
setAgentOperation(operation); setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId); persistAgentUiState(
activeAgentSessionId,
operation.operationId,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
} catch (error) { } catch (error) {
const errorMessage = resolveRpgCreationErrorMessage( const errorMessage = resolveRpgCreationErrorMessage(
error, error,
@@ -694,7 +743,11 @@ export function useRpgCreationSessionController(
error: errorMessage, error: errorMessage,
}), }),
); );
persistAgentUiState(activeAgentSessionId, null); persistAgentUiState(
activeAgentSessionId,
null,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
} }
}, },
[activeAgentSessionId, persistAgentUiState, setSelectionStage], [activeAgentSessionId, persistAgentUiState, setSelectionStage],

View File

@@ -67,6 +67,7 @@ type UseRpgEntryLibraryDetailParams = {
persistAgentUiState: ( persistAgentUiState: (
sessionId: string | null, sessionId: string | null,
operationId: string | null, operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void; ) => void;
syncAgentSessionSnapshot: ( syncAgentSessionSnapshot: (
sessionId: string, sessionId: string,
@@ -244,7 +245,30 @@ export function useRpgEntryLibraryDetail(
work.playableNpcCount <= 0 && work.landmarkCount <= 0; work.playableNpcCount <= 0 && work.landmarkCount <= 0;
try { try {
if (shouldOpenAgentWorkspace) { const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace && !nextProfile) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen(); suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null); persistAgentUiState(work.sessionId, null);
@@ -256,13 +280,16 @@ export function useRpgEntryLibraryDetail(
} }
releaseAgentDraftResultAutoOpenSuppression(); releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) { if (!nextProfile) {
persistAgentUiState(work.sessionId, null); persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate(); setPlatformTabToCreate();
setSelectionStage('agent-workspace'); setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return; return;
} }

View File

@@ -42,4 +42,35 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信'); expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信');
expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅'); expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅');
}); });
it('保留 Agent 发布门槛需要的顶层 worldHook 和 playerPremise', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
summary: '海雾会吞掉记错航线的人。',
worldHook: '在失真的海图上追查一场被篡改的沉船事故。',
playerPremise: '玩家是返乡调查旧案的守灯人。',
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '失灯港',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '玩家在雾港发现灯册被改写。',
},
],
},
],
});
expect(profile?.worldHook).toBe(
'在失真的海图上追查一场被篡改的沉船事故。',
);
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
}); });

View File

@@ -1050,6 +1050,17 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary); const summary = toText(value.summary);
const tone = toText(value.tone); const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal); const playerGoal = toText(value.playerGoal);
const creatorIntentRecord = isRecord(value.creatorIntent)
? value.creatorIntent
: null;
const worldHook = toText(
value.worldHook,
toText(creatorIntentRecord?.worldHook, toText(value.summary, settingText || name)),
);
const playerPremise = toText(
value.playerPremise,
toText(creatorIntentRecord?.playerPremise, playerGoal),
);
const majorFactions = toStringArray(value.majorFactions); const majorFactions = toStringArray(value.majorFactions);
const coreConflicts = toStringArray(value.coreConflicts); const coreConflicts = toStringArray(value.coreConflicts);
const resolvedCoreConflicts = const resolvedCoreConflicts =
@@ -1093,6 +1104,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
summary, summary,
tone, tone,
playerGoal, playerGoal,
worldHook,
playerPremise,
templateWorldType, templateWorldType,
compatibilityTemplateWorldType, compatibilityTemplateWorldType,
majorFactions, majorFactions,

View File

@@ -28,7 +28,6 @@ import {
buildCustomWorldRoleBatchPrompt, buildCustomWorldRoleBatchPrompt,
buildCustomWorldRoleOutlineBatchJsonRepairPrompt, buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
buildCustomWorldRoleOutlineBatchPrompt, buildCustomWorldRoleOutlineBatchPrompt,
buildCustomWorldSceneImagePrompt,
buildCustomWorldStoryGraphJsonRepairPrompt, buildCustomWorldStoryGraphJsonRepairPrompt,
buildCustomWorldStoryGraphPrompt, buildCustomWorldStoryGraphPrompt,
buildCustomWorldThemePackJsonRepairPrompt, buildCustomWorldThemePackJsonRepairPrompt,
@@ -1951,11 +1950,7 @@ export async function generateCustomWorldSceneImage({
size = '1280*720', size = '1280*720',
referenceImageSrc, referenceImageSrc,
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> { }: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
const resolvedPrompt = const resolvedPrompt = prompt?.trim() || userPrompt?.trim() || '';
prompt?.trim() ||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
});
const resolvedNegativePrompt = const resolvedNegativePrompt =
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT; negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
const controller = new AbortController(); const controller = new AbortController();
@@ -1975,9 +1970,25 @@ export async function generateCustomWorldSceneImage({
worldName: profile.name, worldName: profile.name,
landmarkId: landmark.id, landmarkId: landmark.id,
landmarkName: landmark.name, landmarkName: landmark.name,
prompt: resolvedPrompt, ...(prompt?.trim() ? { prompt: prompt.trim() } : {}),
userPrompt: resolvedPrompt,
negativePrompt: resolvedNegativePrompt, negativePrompt: resolvedNegativePrompt,
size, size,
profile: {
id: profile.id,
name: profile.name,
subtitle: profile.subtitle,
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
settingText: profile.settingText,
},
landmark: {
id: landmark.id,
name: landmark.name,
description: landmark.description,
dangerLevel: landmark.dangerLevel,
},
...(referenceImageSrc?.trim() ...(referenceImageSrc?.trim()
? { referenceImageSrc: referenceImageSrc.trim() } ? { referenceImageSrc: referenceImageSrc.trim() }
: {}), : {}),

View File

@@ -45,6 +45,7 @@ test('custom world agent ui state reads from query first and persists to session
{ {
activeSessionId: 'session-1', activeSessionId: 'session-1',
activeOperationId: 'operation-1', activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1', ownerUserId: 'user-1',
}, },
env, env,
@@ -52,15 +53,20 @@ test('custom world agent ui state reads from query first and persists to session
expect(currentUrl).toContain('customWorldSessionId=session-1'); expect(currentUrl).toContain('customWorldSessionId=session-1');
expect(currentUrl).toContain('customWorldOperationId=operation-1'); expect(currentUrl).toContain('customWorldOperationId=operation-1');
expect(currentUrl).toContain(
'customWorldGenerationSource=agent-draft-foundation',
);
expect(readCustomWorldAgentUiState(env)).toEqual({ expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1', activeSessionId: 'session-1',
activeOperationId: 'operation-1', activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
}); });
currentUrl = '/play'; currentUrl = '/play';
expect(readCustomWorldAgentUiState(env)).toEqual({ expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1', activeSessionId: 'session-1',
activeOperationId: 'operation-1', activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1', ownerUserId: 'user-1',
}); });

View File

@@ -2,6 +2,8 @@ import type { CustomWorldAgentUiState } from '../types';
export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId'; export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId'; export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
export const CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY =
'customWorldGenerationSource';
export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY = export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
'genarrative.custom-world-agent-ui.v1'; 'genarrative.custom-world-agent-ui.v1';
@@ -50,6 +52,10 @@ function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function normalizeGenerationSource(value: unknown) {
return value === 'agent-draft-foundation' ? value : null;
}
export function readCustomWorldAgentUiState( export function readCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment, env?: CustomWorldAgentUiEnvironment,
): CustomWorldAgentUiState { ): CustomWorldAgentUiState {
@@ -62,9 +68,16 @@ export function readCustomWorldAgentUiState(
activeOperationId: normalizeValue( activeOperationId: normalizeValue(
params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY), params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
), ),
customWorldGenerationSource: normalizeGenerationSource(
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
),
}; };
if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) { if (
stateFromQuery.activeSessionId ||
stateFromQuery.activeOperationId ||
stateFromQuery.customWorldGenerationSource
) {
return stateFromQuery; return stateFromQuery;
} }
@@ -80,6 +93,9 @@ export function readCustomWorldAgentUiState(
return { return {
activeSessionId: normalizeValue(parsed.activeSessionId), activeSessionId: normalizeValue(parsed.activeSessionId),
activeOperationId: normalizeValue(parsed.activeOperationId), activeOperationId: normalizeValue(parsed.activeOperationId),
customWorldGenerationSource: normalizeGenerationSource(
parsed.customWorldGenerationSource,
),
ownerUserId: normalizeValue(parsed.ownerUserId), ownerUserId: normalizeValue(parsed.ownerUserId),
}; };
} catch { } catch {
@@ -95,10 +111,14 @@ export function writeCustomWorldAgentUiState(
const resolved = resolveEnvironment(env); const resolved = resolveEnvironment(env);
const activeSessionId = normalizeValue(state.activeSessionId); const activeSessionId = normalizeValue(state.activeSessionId);
const activeOperationId = normalizeValue(state.activeOperationId); const activeOperationId = normalizeValue(state.activeOperationId);
const customWorldGenerationSource = normalizeGenerationSource(
state.customWorldGenerationSource,
);
const ownerUserId = normalizeValue(state.ownerUserId); const ownerUserId = normalizeValue(state.ownerUserId);
const nextState: CustomWorldAgentUiState = { const nextState: CustomWorldAgentUiState = {
activeSessionId, activeSessionId,
activeOperationId, activeOperationId,
customWorldGenerationSource,
ownerUserId, ownerUserId,
}; };
@@ -116,6 +136,15 @@ export function writeCustomWorldAgentUiState(
params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY); params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
} }
if (customWorldGenerationSource) {
params.set(
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
customWorldGenerationSource,
);
} else {
params.delete(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY);
}
const search = params.toString(); const search = params.toString();
const nextUrl = search const nextUrl = search
? `${resolved.location.pathname}?${search}` ? `${resolved.location.pathname}?${search}`
@@ -124,7 +153,7 @@ export function writeCustomWorldAgentUiState(
} }
if (resolved.sessionStorage) { if (resolved.sessionStorage) {
if (activeSessionId || activeOperationId) { if (activeSessionId || activeOperationId || customWorldGenerationSource) {
resolved.sessionStorage.setItem( resolved.sessionStorage.setItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY, CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
JSON.stringify(nextState), JSON.stringify(nextState),

View File

@@ -29,6 +29,7 @@ export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
export type CustomWorldAgentUiState = { export type CustomWorldAgentUiState = {
activeSessionId?: string | null; activeSessionId?: string | null;
activeOperationId?: string | null; activeOperationId?: string | null;
customWorldGenerationSource?: 'agent-draft-foundation' | null;
ownerUserId?: string | null; ownerUserId?: string | null;
}; };
@@ -397,6 +398,16 @@ export interface CustomWorldProfile {
summary: string; summary: string;
tone: string; tone: string;
playerGoal: string; playerGoal: string;
/**
* 发布门槛直接读取的世界一句话钩子。
* Agent 结果页回写 session 时需要保留该字段,避免只剩 UI 归一化字段导致后端误判缺失。
*/
worldHook?: string | null;
/**
* 发布门槛直接读取的玩家身份与切入前提。
* 即使 creatorIntent / anchorContent 中已有结构化信息,也要保留顶层字段作为 SpacetimeDB 发布快照的稳定兼容槽位。
*/
playerPremise?: string | null;
cover?: CustomWorldCoverProfile | null; cover?: CustomWorldCoverProfile | null;
templateWorldType: WorldTemplateType; templateWorldType: WorldTemplateType;
compatibilityTemplateWorldType?: WorldTemplateType | null; compatibilityTemplateWorldType?: WorldTemplateType | null;