Auto-open draft result after foundation completes
This commit is contained in:
35281
.codex/tmp-schema.json
Normal file
35281
.codex/tmp-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 主源。
|
||||||
|
|||||||
@@ -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. 一句话总结
|
||||||
|
|
||||||
这个项目真正的开发经验不是“怎么多写一个按钮”,而是:
|
这个项目真正的开发经验不是“怎么多写一个按钮”,而是:
|
||||||
|
|
||||||
|
|||||||
@@ -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` 和部分成功保留逻辑可复用。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
## 后续注意
|
## 后续注意
|
||||||
|
|||||||
@@ -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 真相态。
|
||||||
|
|||||||
@@ -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` 仍应保留,不做前端假放行。
|
||||||
@@ -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 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。
|
||||||
|
|||||||
@@ -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 主工程改动按对应模块落位,不再继续堆回单大文件。
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头";
|
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头";
|
||||||
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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user