From 6cb3efae61f066dd79132c331046ae41516f0672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 24 Apr 2026 17:59:48 +0800 Subject: [PATCH] 1 --- ...WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md | 20 + ...CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md | 27 ++ ...FT_ENTITY_CATALOG_LAYOUT_FIX_2026-04-24.md | 17 + ...DRAFT_TEST_AND_PUBLISH_PANEL_2026-04-24.md | 30 ++ ...REATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md | 1 + ..._WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md | 54 +++ ...RESULT_ENTITY_GENERATION_FIX_2026-04-24.md | 13 + ...ER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md | 64 ++- ...REATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md | 2 + server-rs/crates/api-server/src/app.rs | 39 +- server-rs/crates/api-server/src/big_fish.rs | 27 ++ .../src/character_animation_assets.rs | 83 +++- .../api-server/src/character_visual_assets.rs | 90 ++++ .../crates/api-server/src/custom_world.rs | 386 ++++++++++++++++-- .../crates/api-server/src/custom_world_ai.rs | 116 ++++++ .../src/custom_world_foundation_draft.rs | 60 ++- server-rs/crates/api-server/src/puzzle.rs | 36 ++ server-rs/crates/module-big-fish/src/lib.rs | 7 + server-rs/crates/module-puzzle/src/lib.rs | 7 + .../crates/shared-contracts/src/assets.rs | 6 + .../crates/spacetime-client/src/big_fish.rs | 24 ++ .../spacetime-client/src/custom_world.rs | 24 ++ server-rs/crates/spacetime-client/src/lib.rs | 35 +- .../big_fish_work_delete_input_type.rs | 23 ++ .../delete_big_fish_work_procedure.rs | 57 +++ ...te_custom_world_agent_session_procedure.rs | 57 +++ .../delete_puzzle_work_procedure.rs | 57 +++ .../src/module_bindings/mod.rs | 7 + .../puzzle_work_delete_input_type.rs | 23 ++ .../crates/spacetime-client/src/puzzle.rs | 24 ++ .../spacetime-module/src/big_fish/session.rs | 89 ++++ .../spacetime-module/src/custom_world/mod.rs | 267 +++++++++++- server-rs/crates/spacetime-module/src/lib.rs | 29 +- .../crates/spacetime-module/src/puzzle.rs | 89 +++- src/components/CustomWorldEntityCatalog.tsx | 125 +----- src/components/CustomWorldGenerationView.tsx | 24 +- .../characterAssetWorkflowPersistence.ts | 12 +- ...ustomWorldCreationHub.interaction.test.tsx | 4 +- .../CustomWorldCreationHub.tsx | 24 +- .../CustomWorldCreationStartCard.tsx | 18 +- .../custom-world-home/CustomWorldWorkCard.tsx | 65 ++- .../custom-world-home/CustomWorldWorkTabs.tsx | 4 +- .../PlatformEntryFlowShellImpl.tsx | 166 +++++--- .../RpgCreationRoleAssetStudioModalImpl.tsx | 30 +- .../RpgCreationEntityEditorShared.tsx | 168 +++++--- .../RpgCreationResultActionBar.tsx | 159 ++++++-- .../RpgCreationResultViewImpl.tsx | 17 +- .../rpg-entry/useRpgCreationEnterWorld.ts | 42 +- .../big-fish-works/bigFishWorksClient.ts | 23 ++ src/services/big-fish-works/index.ts | 6 +- src/services/puzzle-works/index.ts | 1 + .../puzzle-works/puzzleWorksClient.ts | 17 + src/services/rpg-creation/index.ts | 1 + .../rpg-creation/rpgCreationWorkClient.ts | 11 + src/types/customWorld.ts | 1 + 55 files changed, 2373 insertions(+), 435 deletions(-) create mode 100644 docs/design/CREATION_WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md create mode 100644 docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md create mode 100644 docs/experience/PC_WORLD_DRAFT_ENTITY_CATALOG_LAYOUT_FIX_2026-04-24.md create mode 100644 docs/experience/WORLD_DRAFT_TEST_AND_PUBLISH_PANEL_2026-04-24.md create mode 100644 docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_delete_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_delete_input_type.rs diff --git a/docs/design/CREATION_WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md b/docs/design/CREATION_WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md new file mode 100644 index 00000000..77889b35 --- /dev/null +++ b/docs/design/CREATION_WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md @@ -0,0 +1,20 @@ +# 创作页作品删除入口设计 2026-04-24 + +## 背景 + +创作页作品卡曾把删除作为底部大按钮展示,并且只对带 `profileId` 的 RPG 作品传入删除回调,导致大鱼、拼图、以及部分草稿作品没有删除入口。用户预期是:删除不是主操作,放在卡片右上角的小 icon 即可;任何作品都应该能删除。 + +## 落地规则 + +- 作品卡右上角固定展示删除 icon,底部主操作区只保留继续创作、查看详情、体验等正向操作。 +- 删除入口不按发布状态隐藏:草稿、已发布作品均可删除。 +- 删除入口不按玩法类型隐藏:RPG、大鱼吃小鱼、拼图作品均应在创作页可删除。 +- 点击删除前保留浏览器确认弹窗,避免误触;删除中仅禁用当前作品卡的删除 icon。 +- 删除成功后刷新或替换对应玩法的作品列表,确保卡片立即消失。 + +## 工程边界 + +- 前端只负责表现和触发删除,实际删除由 `server-rs` API 与 SpacetimeDB 模块过程完成。 +- 大鱼作品按 `sourceSessionId` 删除创作 session,并同步清理消息、素材槽和运行快照。 +- 拼图作品按 `profileId` 删除作品 profile,并同步清理来源 Agent session、消息和入口运行快照。 +- RPG 已发布/持久草稿按 `profileId` 走既有自定义世界删除链路;纯 Agent session 草稿按 `sessionId` 走 owner-only session 删除过程,并清理消息、操作与草稿卡。 diff --git a/docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md b/docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md new file mode 100644 index 00000000..ec1936ab --- /dev/null +++ b/docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md @@ -0,0 +1,27 @@ +# PC 端世界生成与草稿页布局优化说明 2026-04-24 + +## 目标 + +在移动端现有布局不变的前提下,只优化 PC 端世界生成页与世界草稿页的信息组织,让页面更紧凑、更有层次,并保留全部已有功能入口。 + +## 范围 + +- 世界生成页:`src/components/CustomWorldGenerationView.tsx` +- 世界草稿页 / 作品页:`src/components/custom-world-home/CustomWorldCreationHub.tsx` +- 新建作品入口:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx` +- 作品卡片:`src/components/custom-world-home/CustomWorldWorkCard.tsx` +- 筛选标签:`src/components/custom-world-home/CustomWorldWorkTabs.tsx` + +## PC 端落地规则 + +1. 移动端默认类名保持原布局语义,只通过 `lg:` / `xl:` 断点追加 PC 布局。 +2. 世界生成页在 PC 端改为左右双栏:左侧突出进度与阶段,右侧承载玩家设定 / 结构化锚点,减少纵向滚动。 +3. 世界草稿页在 PC 端将“新建作品”和“作品列表”分区强化:顶部入口更紧凑,作品卡片网格密度提升。 +4. 不新增规则说明文案,不改变按钮、筛选、删除、体验、进入等功能行为。 +5. 中文文本只做必要保留,不因为布局调整改写已有中文内容。 + +## 视觉策略 + +- PC 端使用更明确的 `xl:grid`、固定信息侧栏和更小间距,让主内容首屏承载更多信息。 +- 卡片在 PC 端降低无效高度,操作按钮与状态信息尽量同行展示。 +- 保留现有 `platform-*` 视觉体系,避免引入新的 UI 系统。 diff --git a/docs/experience/PC_WORLD_DRAFT_ENTITY_CATALOG_LAYOUT_FIX_2026-04-24.md b/docs/experience/PC_WORLD_DRAFT_ENTITY_CATALOG_LAYOUT_FIX_2026-04-24.md new file mode 100644 index 00000000..3e59794a --- /dev/null +++ b/docs/experience/PC_WORLD_DRAFT_ENTITY_CATALOG_LAYOUT_FIX_2026-04-24.md @@ -0,0 +1,17 @@ +# PC 世界档案草稿编辑页布局修正 2026-04-24 + +## 背景 + +用户反馈 PC 端世界草稿页没有明显变化。复核截图后确认实际页面是世界档案草稿编辑页中的实体目录,而不是创作首页作品列表。 + +## 本次修正范围 + +- `src/components/CustomWorldEntityCatalog.tsx` + +## 落地要求 + +1. 移动端仍保持原来的单列滚动、顶部标签与搜索结构。 +2. PC 端把超宽单列实体列表改成卡片网格,减少横向空白,提高信息密度。 +3. PC 端顶部世界标题、标签、搜索和操作按钮更紧凑,避免首屏被空白标题区占用。 +4. 功能不变:搜索、切换标签、新增、批量删除、选择、编辑、发布入口均保持原有行为。 +5. 不新增说明类文案,不改写已有中文内容。 diff --git a/docs/experience/WORLD_DRAFT_TEST_AND_PUBLISH_PANEL_2026-04-24.md b/docs/experience/WORLD_DRAFT_TEST_AND_PUBLISH_PANEL_2026-04-24.md new file mode 100644 index 00000000..7efefd92 --- /dev/null +++ b/docs/experience/WORLD_DRAFT_TEST_AND_PUBLISH_PANEL_2026-04-24.md @@ -0,0 +1,30 @@ +# 世界草稿发布面板与测试入口设计 2026-04-24 + +## 目标 + +世界草稿页底部不再只有“发布并进入世界”一个动作,而是拆成两个明确入口: + +1. 作品测试:跳过发布阻断项检查,直接进入当前草稿游戏体验。 +2. 发布:打开发布面板,在面板内集中处理发布阻断项与封面设置,满足条件后发布到广场。 + +## 页面范围 + +- `src/components/rpg-creation-result/RpgCreationResultActionBar.tsx` +- `src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx` +- `src/components/CustomWorldEntityCatalog.tsx` +- `src/components/rpg-entry/useRpgCreationEnterWorld.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +## 交互规则 + +1. 世界草稿页右下角显示“作品测试”和“发布”两个按钮。 +2. “作品测试”只同步当前草稿结果并进入游戏,不触发发布阻断项检查,也不执行发布动作。 +3. “发布”打开独立发布面板,不在当前页面下方展开内容。 +4. 发布面板显示当前阻断项;没有阻断项时允许执行发布。 +5. 发布面板显示封面预览与封面状态,并提供“设置封面”入口。 +6. 封面生成、封面上传与封面预览从世界档案页迁移到发布面板;世界档案页不再展示作品封面模块。 +7. 移动端仍使用底部弹层式面板,PC 端使用居中 modal。 + +## 发布后行为 + +发布成功后刷新本地结果档案,并进入正式世界;作品由既有 `publish_world` 流程同步到作品库 / 广场。 diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md index f3e4a935..db992d2f 100644 --- a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -96,6 +96,7 @@ 7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。 8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。 9. 不在草稿生成阶段默认补动作、待机、攻击、跑动或技能动作素材。 +10. RPG 世界草稿的可扮演角色、场景角色与场景列表项不再直接展示“生成资产 / 生成场景图”按钮;资产生成由草稿生成链路或后续专门工坊入口承接,列表卡片只保留浏览、编辑、选择和删除等核心操作。 --- diff --git a/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md b/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md new file mode 100644 index 00000000..8e9b3da1 --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md @@ -0,0 +1,54 @@ +# 自定义世界资产 Prompt 与默认描述配置说明(2026-04-24) + +## 1. 目标 + +本说明记录生成世界草稿时,角色形象图像、角色动作视频、每一幕场景背景图像三类资产的默认描述与正式模型 prompt 的配置位置,避免后续继续误改旧 `server-node` 链路。 + +## 2. 世界草稿默认描述字段 + +生成世界草稿时,后端会要求模型在角色与幕级剧情结构阶段直接产出资产默认描述字段: + +- 角色:`visualDescription`,用于打开角色形象图像生成面板时默认填入角色形象描述框。 +- 角色:`actionDescription`,用于打开角色动作视频生成面板时默认填入各动作描述框;当前每个动作会从同一角色默认动作描述起步,用户切换动作后可分别编辑并缓存。 +- 每一幕:`sceneChapterBlueprints[*].acts[*].backgroundPromptText`,用于打开该幕背景图像生成面板时默认填入场景描述框。 +- 场景:`visualDescription` 只作为旧场景图或没有幕级描述时的兜底,不再从角色 AI 形象生成面板维护场景背景描述。 + +草稿生成契约位置: + +- `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` + - `build_custom_world_role_outline_batch_prompt` + - `build_custom_world_landmark_seed_batch_prompt` + - `build_foundation_draft_user_prompt` + - `normalize_scene_act_blueprint` + +前端默认框映射位置: + +- `src/prompts/customWorldRolePromptDefaults.ts` + - `visualPromptText` 优先取 `role.visualDescription`。 + - `animationPromptText` 优先取 `role.actionDescription`。 +- `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx` + - 角色形象与动作工坊初始化默认文本。 + - `animationPromptTextByKey` 负责分动作保存动作描述。 + - 当角色本身已有 `visualDescription/actionDescription` 时,必须优先使用这批世界草稿新生成字段,不能让旧 workflow cache 覆盖当前草稿默认文本。 +- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` + - 幕背景图像生成弹窗优先使用 `act.backgroundPromptText`。 + - 普通场景图像生成弹窗仍可使用 `landmark.visualDescription` 兜底。 + +## 3. 正式模型 Prompt 配置 + +正式生成图片或视频时,不直接使用默认描述字段作为完整 prompt,而是在 `server-rs` 继续编译: + +- 角色主图:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs` + - `build_character_visual_prompt` + - 内部使用 `build_master_prompt` +- 角色动作视频:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs` + - `build_character_animation_prompt` + - 图生视频分支使用 `build_video_action_prompt` +- 场景背景图:`server-rs/crates/api-server/src/custom_world_ai.rs` + - `build_custom_world_scene_image_prompt` + +## 4. 当前约束 + +- 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。 +- 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。 +- UI 不默认展示规则解释文案,正式约束只进入后端 prompt。 diff --git a/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md b/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md index fe4335de..cecdcf58 100644 --- a/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md @@ -38,3 +38,16 @@ LLM 扩展提示词为了草稿卡片简洁,只要求返回角色的 `publicMa - `generate_landmarks` 的 payload 中新增场景必须包含非空 `description`,并能把 `characterIds` 落为 `sceneNpcIds`。 - 结果页继续只消费 `resultPreview.preview`,不新增前端本地编译分支。 - 结果页点击新增实体后,如果服务端没有回传新增内容,必须展示错误提示。 + +## 2026-04-24 追加:可扮演角色结果页空刷新修复 + +新增可扮演角色报“生成请求已完成,但结果页未收到新增内容”的根因是:`api-server` 已经把 LLM 生成结果注入 `generatedCharacters`,但 `spacetime-module` 缺少 `generate_characters / generate_landmarks` 的真实落库 executor,action 会进入分派却无法把新增内容写入 `draft_profile_json` 与 `result_preview_json`。 + +本次补齐 SpacetimeDB module executor: + +1. `generate_characters(roleType=playable)` 写入 `draftProfile.playableNpcs`。 +2. `generate_characters(roleType=story)` 写入 `draftProfile.storyNpcs`。 +3. `generate_landmarks` 写入 `draftProfile.landmarks`。 +4. 每个新增实体同步生成 draft card,并刷新 `publish_gate_json / result_preview_json / checkpoints_json / operation / message`。 + +结果页仍只消费服务端 `resultPreview.preview`;前端不会本地伪造新增角色。 diff --git a/docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md b/docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md index fd41887e..b8e2a1f0 100644 --- a/docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md +++ b/docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md @@ -13,6 +13,18 @@ 目标是把旧 Node 写入本地 `public/generated-character-drafts/*/workflow-cache.json` 的临时缓存,切到 OSS JSON 草稿对象,继续保持前端当前可消费 contract。 +## 1.1 2026-04-24 修订:按作品隔离缓存 + +世界草稿生成后的首个场景角色经常使用 `hero / npc-1` 等稳定角色 ID。若缓存对象键只包含 `characterId`,不同作品打开同名角色时会复用上一部作品的 `visualPromptText / imageSrc / visualDrafts`,导致“AI 角色生成的形象描述默认文本”和当前剧本无关。 + +本修订冻结以下口径: + +1. 前端在角色资产工坊读写缓存时必须传入 `cacheScopeId`,默认取当前作品 `profile.id`。 +2. Rust 保存接口接受可选 `cacheScopeId`,并写回缓存 JSON,便于读取时校验归属。 +3. 新缓存对象键改为 `generated-character-drafts/{cacheScopeSegment}/{characterSegment}/workflow-cache/workflow-cache.json`。 +4. 未带 `cacheScopeId` 的旧请求仍走历史对象键,避免破坏旧工具或历史兼容入口。 +5. 新前端不会回退读取旧无作品维度缓存,避免把跨作品旧缓存再次带入新世界草稿。 + ## 2. 当前前提 当前仓库已经具备以下能力: @@ -63,15 +75,16 @@ 请求结构继续保持前端当前字段: 1. `characterId` -2. `visualPromptText` -3. `animationPromptText` -4. `visualDrafts` -5. `selectedVisualDraftId` -6. `selectedAnimation` -7. `imageSrc` -8. `generatedVisualAssetId` -9. `generatedAnimationSetId` -10. `animationMap` +2. `cacheScopeId`:可选;当前自定义世界作品写入 `profile.id` +3. `visualPromptText` +4. `animationPromptText` +5. `visualDrafts` +6. `selectedVisualDraftId` +7. `selectedAnimation` +8. `imageSrc` +9. `generatedVisualAssetId` +10. `generatedAnimationSetId` +11. `animationMap` 返回结构继续保持: @@ -83,28 +96,36 @@ 缓存 JSON 固定写入: +新前端写入: + +`generated-character-drafts/{cacheScopeSegment}/{characterSegment}/workflow-cache/workflow-cache.json` + +旧兼容入口未提供 `cacheScopeId` 时继续写入: + `generated-character-drafts/{characterSegment}/workflow-cache/workflow-cache.json` 其中: -1. `characterSegment` 来自 `characterId` 的安全路径片段 -2. 文件名固定为 `workflow-cache.json` -3. content type 固定为 `application/json; charset=utf-8` +1. `cacheScopeSegment` 来自 `cacheScopeId` 的安全路径片段,通常等于作品 `profile.id` +2. `characterSegment` 来自 `characterId` 的安全路径片段 +3. 文件名固定为 `workflow-cache.json` +4. content type 固定为 `application/json; charset=utf-8` ## 6. 字段归一化规则 保存接口固定执行以下归一化: 1. `characterId` 必填,trim 后不能为空 -2. `visualPromptText` 最长保留 280 字 -3. `animationPromptText` 最长保留 280 字 -4. `visualDrafts` 只保留有 `imageSrc` 的候选 -5. `visualDrafts[].width` 默认 `1024` -6. `visualDrafts[].height` 默认 `1536` -7. `selectedAnimation` 默认 `idle` -8. 空 `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化 -9. 非对象 `animationMap` 归一化为 `null` -10. `updatedAt` 由 Rust 服务端生成 UTC 时间 +2. `cacheScopeId` trim 后为空则视为旧兼容缓存,不写新作品目录 +3. `visualPromptText` 最长保留 280 字 +4. `animationPromptText` 最长保留 280 字 +5. `visualDrafts` 只保留有 `imageSrc` 的候选 +6. `visualDrafts[].width` 默认 `1024` +7. `visualDrafts[].height` 默认 `1536` +8. `selectedAnimation` 默认 `idle` +9. 空 `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化 +10. 非对象 `animationMap` 归一化为 `null` +11. `updatedAt` 由 Rust 服务端生成 UTC 时间 ## 7. 元数据规范 @@ -115,6 +136,7 @@ 3. `entity_kind = character` 4. `entity_id = characterId` 5. `slot = workflow_cache` +6. `cache_scope_id = cacheScopeId`,仅新作品维度缓存写入 说明: diff --git a/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md b/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md index b63c1404..8fe6bbc2 100644 --- a/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md +++ b/docs/technical/UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md @@ -103,6 +103,8 @@ src/services/creation-agent/ 3. 补全剩余设定话术。 4. 生成结果页 action:`draft_foundation`。 +`draft_foundation` 的生成进度必须展示并真实执行后端后台任务阶段:世界骨架、角色/场景结构、底稿编译、角色主形象生成、幕背景图生成、草稿卡编译、结果页写回。角色主形象与幕背景图不能只作为 UI 进度占位;后台任务必须调用素材生成链路,将角色 `imageSrc / generatedVisualAssetId` 与场景幕 `backgroundImageSrc / backgroundAssetId` 写回 `draftProfile` 后,才能继续进入草稿卡编译与结果页写回。 + ### 4.2 大鱼吃小鱼 保留差异: diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c40cfe80..9beca63a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4,7 +4,7 @@ use axum::{ extract::Extension, http::Request, middleware, - routing::{get, post}, + routing::{delete, get, post}, }; use tower_http::{ classify::ServerErrorsFailureClass, @@ -33,9 +33,9 @@ use crate::{ auth_public_user::{get_public_user_by_code, get_public_user_by_id}, auth_sessions::auth_sessions, big_fish::{ - create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session, - get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input, - submit_big_fish_message, + create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, + get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message, + submit_big_fish_input, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -46,8 +46,9 @@ use crate::{ generate_character_visual, get_character_visual_job, publish_character_visual, }, custom_world::{ - create_custom_world_agent_session, delete_custom_world_library_profile, - execute_custom_world_agent_action, get_custom_world_agent_card_detail, + create_custom_world_agent_session, delete_custom_world_agent_session, + delete_custom_world_library_profile, execute_custom_world_agent_action, + get_custom_world_agent_card_detail, get_custom_world_agent_operation, get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code, get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, @@ -76,10 +77,10 @@ use crate::{ password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, puzzle::{ - advance_puzzle_next_level, create_puzzle_agent_session, drag_puzzle_piece_or_group, - execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail, - get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, - put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, + advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work, + drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session, + get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, + list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, swap_puzzle_pieces, }, refresh_session::refresh_session, @@ -442,10 +443,12 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/runtime/custom-world/agent/sessions/{session_id}", - get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + get(get_custom_world_agent_session) + .delete(delete_custom_world_agent_session) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/custom-world/works", @@ -531,6 +534,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/works/{session_id}", + delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/sessions/{session_id}/runs", post(start_big_fish_run).route_layer(middleware::from_fn_with_state( @@ -598,6 +608,7 @@ pub fn build_router(state: AppState) -> Router { "/api/runtime/puzzle/works/{profile_id}", get(get_puzzle_work_detail) .put(put_puzzle_work) + .delete(delete_puzzle_work) .route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 9b61717d..3873195e 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -134,6 +134,33 @@ pub async fn get_big_fish_works( )) } +pub async fn delete_big_fish_work( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .delete_big_fish_work(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn submit_big_fish_message( State(state): State, Path(session_id): Path, diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 3e6d3856..3dbafcaa 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -9,7 +9,7 @@ use std::{ use axum::{ Json, - extract::{Extension, Path as AxumPath, State, rejection::JsonRejection}, + extract::{Extension, Path as AxumPath, Query, State, rejection::JsonRejection}, http::StatusCode, response::Response, }; @@ -29,6 +29,7 @@ use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; +use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::assets::{ CharacterAnimationDraftPayload, CharacterAnimationGenerateRequest, @@ -58,6 +59,13 @@ const CHARACTER_ANIMATION_ASSET_KIND: &str = "character_animation"; const CHARACTER_ANIMATION_REFERENCE_ASSET_KIND: &str = "character_animation_reference_video"; const CHARACTER_WORKFLOW_CACHE_ASSET_KIND: &str = "character_workflow_cache"; const CHARACTER_ANIMATION_ENTITY_KIND: &str = "character"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CharacterWorkflowCacheQuery { + #[serde(default)] + pub cache_scope_id: Option, +} const CHARACTER_ANIMATION_SLOT: &str = "animation_set"; const CHARACTER_ANIMATION_REFERENCE_SLOT: &str = "animation_reference_video"; const CHARACTER_WORKFLOW_CACHE_SLOT: &str = "workflow_cache"; @@ -573,6 +581,7 @@ pub async fn get_character_workflow_cache( State(state): State, Extension(request_context): Extension, AxumPath(character_id): AxumPath, + Query(query): Query, ) -> Result, Response> { let character_id = normalize_required_text(character_id.as_str(), ""); if character_id.is_empty() { @@ -585,7 +594,8 @@ pub async fn get_character_workflow_cache( )); } - let cache = load_workflow_cache(&state, character_id.as_str()) + let cache_scope_id = trim_optional_text(query.cache_scope_id.as_deref()); + let cache = load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref()) .await .map_err(|error| character_animation_error_response(&request_context, error))?; @@ -1530,9 +1540,10 @@ async fn put_imported_video_object( async fn load_workflow_cache( state: &AppState, character_id: &str, + cache_scope_id: Option<&str>, ) -> Result, AppError> { let oss_client = require_oss_client(state)?; - let object_key = workflow_cache_object_key(character_id); + let object_key = workflow_cache_object_key(character_id, cache_scope_id); let signed = match oss_client.sign_get_object_url(OssSignedGetObjectUrlRequest { object_key, expire_seconds: Some(60), @@ -1571,7 +1582,7 @@ async fn load_workflow_cache( })) })?; - if cache.character_id == character_id { + if cache.character_id == character_id && cache.cache_scope_id.as_deref() == cache_scope_id { Ok(Some(cache)) } else { Ok(None) @@ -1595,14 +1606,15 @@ async fn save_workflow_cache( &reqwest::Client::new(), OssPutObjectRequest { prefix: LegacyAssetPrefix::CharacterDrafts, - path_segments: vec![ - sanitize_storage_segment(cache.character_id.as_str(), "character"), - "workflow-cache".to_string(), - ], + path_segments: workflow_cache_path_segments(&cache), file_name: "workflow-cache.json".to_string(), content_type: Some("application/json; charset=utf-8".to_string()), access: OssObjectAccess::Private, - metadata: build_workflow_cache_metadata("asset-tool", cache.character_id.as_str()), + metadata: build_workflow_cache_metadata( + "asset-tool", + cache.character_id.as_str(), + cache.cache_scope_id.as_deref(), + ), body, }, ) @@ -1616,8 +1628,10 @@ fn normalize_workflow_cache_payload( updated_at: String, ) -> CharacterWorkflowCachePayload { let character_id = normalize_required_text(payload.character_id.as_str(), "character"); + let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref()); CharacterWorkflowCachePayload { character_id: character_id.clone(), + cache_scope_id, visual_prompt_text: clamp_prompt_seed_text(payload.visual_prompt_text.as_deref()), animation_prompt_text: clamp_prompt_seed_text(payload.animation_prompt_text.as_deref()), visual_drafts: normalize_visual_drafts(character_id.as_str(), payload.visual_drafts), @@ -1661,11 +1675,32 @@ fn normalize_visual_drafts( .collect() } -fn workflow_cache_object_key(character_id: &str) -> String { - format!( - "generated-character-drafts/{}/workflow-cache/workflow-cache.json", - sanitize_storage_segment(character_id, "character") - ) +fn workflow_cache_path_segments(cache: &CharacterWorkflowCachePayload) -> Vec { + let character_segment = sanitize_storage_segment(cache.character_id.as_str(), "character"); + if let Some(cache_scope_id) = cache.cache_scope_id.as_deref() { + vec![ + sanitize_storage_segment(cache_scope_id, "world"), + character_segment, + "workflow-cache".to_string(), + ] + } else { + vec![character_segment, "workflow-cache".to_string()] + } +} + +fn workflow_cache_object_key(character_id: &str, cache_scope_id: Option<&str>) -> String { + if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) { + format!( + "generated-character-drafts/{}/{}/workflow-cache/workflow-cache.json", + sanitize_storage_segment(cache_scope_id.as_str(), "world"), + sanitize_storage_segment(character_id, "character") + ) + } else { + format!( + "generated-character-drafts/{}/workflow-cache/workflow-cache.json", + sanitize_storage_segment(character_id, "character") + ) + } } fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload { @@ -2460,8 +2495,9 @@ fn build_asset_metadata( fn build_workflow_cache_metadata( owner_user_id: &str, character_id: &str, + cache_scope_id: Option<&str>, ) -> BTreeMap { - BTreeMap::from([ + let mut metadata = BTreeMap::from([ ( "asset_kind".to_string(), CHARACTER_WORKFLOW_CACHE_ASSET_KIND.to_string(), @@ -2476,7 +2512,11 @@ fn build_workflow_cache_metadata( "slot".to_string(), CHARACTER_WORKFLOW_CACHE_SLOT.to_string(), ), - ]) + ]); + if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) { + metadata.insert("cache_scope_id".to_string(), cache_scope_id); + } + metadata } fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { @@ -3311,6 +3351,7 @@ mod tests { let cache = normalize_workflow_cache_payload( CharacterWorkflowCacheSaveRequest { character_id: "hero".to_string(), + cache_scope_id: None, visual_prompt_text: Some("主形象".to_string()), animation_prompt_text: Some("待机".to_string()), visual_drafts: vec![CharacterVisualDraftPayload { @@ -3341,11 +3382,19 @@ mod tests { #[test] fn workflow_cache_object_key_uses_character_drafts_prefix() { assert_eq!( - workflow_cache_object_key("Hero 01"), + workflow_cache_object_key("Hero 01", None), "generated-character-drafts/hero-01/workflow-cache/workflow-cache.json" ); } + #[test] + fn workflow_cache_object_key_can_scope_by_world() { + assert_eq!( + workflow_cache_object_key("Hero 01", Some("World 99")), + "generated-character-drafts/world-99/hero-01/workflow-cache/workflow-cache.json" + ); + } + #[test] fn build_animation_generate_result_payload_keeps_image_sequence_shape() { let payload = build_animation_generate_result_payload(&CharacterAnimationGeneratedDraft { diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 9c91e0fe..e6e0f281 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -48,6 +48,12 @@ const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500; +#[derive(Clone, Debug)] +pub(crate) struct GeneratedCharacterPrimaryVisual { + pub image_src: String, + pub asset_id: String, +} + pub async fn generate_character_visual( State(state): State, Extension(request_context): Extension, @@ -277,6 +283,90 @@ pub async fn generate_character_visual( )) } +pub(crate) async fn generate_character_primary_visual_for_profile( + state: &AppState, + owner_user_id: &str, + character_id: &str, + prompt_text: &str, + character_brief_text: Option<&str>, +) -> Result { + let payload = CharacterVisualGenerateRequest { + character_id: character_id.to_string(), + source_mode: shared_contracts::assets::CharacterVisualSourceMode::TextToImage, + prompt_text: prompt_text.to_string(), + character_brief_text: character_brief_text.map(ToOwned::to_owned), + reference_image_data_urls: Vec::new(), + candidate_count: 1, + image_model: CHARACTER_VISUAL_MODEL.to_string(), + size: "1024*1024".to_string(), + }; + let task_id = generate_ai_task_id(current_utc_micros()); + let prompt = build_character_visual_prompt( + payload.prompt_text.as_str(), + payload.character_brief_text.as_deref(), + ); + let character_id = normalize_required_text(payload.character_id.as_str(), "character"); + let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); + let size = normalize_required_text(payload.size.as_str(), "1024*1024"); + create_visual_task( + state, + &task_id, + owner_user_id, + &character_id, + &model, + &prompt, + )?; + let settings = require_dashscope_settings(state)?; + let http_client = build_dashscope_http_client(&settings)?; + state + .ai_task_service() + .start_task(task_id.as_str(), current_utc_micros()) + .map_err(map_ai_task_error)?; + let generated = create_character_visual_generation( + &http_client, + &settings, + model.as_str(), + prompt.as_str(), + size.as_str(), + 1, + &[], + ) + .await?; + let drafts = persist_visual_drafts( + state, + owner_user_id, + &character_id, + &task_id, + generated.images, + size.as_str(), + ) + .await?; + let draft = drafts.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-visual", + "message": "角色主形象生成没有返回候选图。", + })) + })?; + let asset_id = format!("visual-{character_id}-{task_id}"); + let image_src = persist_published_visual( + state, + owner_user_id, + &character_id, + asset_id.as_str(), + draft.image_src.as_str(), + Some(prompt.as_str()), + ) + .await?; + state + .ai_task_service() + .complete_task(task_id.as_str(), current_utc_micros()) + .map_err(map_ai_task_error)?; + Ok(GeneratedCharacterPrimaryVisual { + image_src, + asset_id, + }) +} + pub async fn get_character_visual_job( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 14240199..b1af2a44 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -43,11 +43,13 @@ use tracing::info; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, + character_visual_assets::generate_character_primary_visual_for_profile, custom_world_agent_entities::generate_custom_world_agent_entities, custom_world_agent_turn::{ CustomWorldAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_custom_world_agent_turn, }, + custom_world_ai::generate_custom_world_scene_image_for_profile, custom_world_foundation_draft::{ DraftFoundationPayloadError, build_draft_foundation_action_payload_json, generate_custom_world_foundation_draft, @@ -504,6 +506,36 @@ pub async fn get_custom_world_works( )) } +pub async fn delete_custom_world_agent_session( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .delete_custom_world_agent_session( + session_id, + authenticated.claims().user_id().to_string(), + ) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldWorksResponse { + items: items + .into_iter() + .map(map_custom_world_work_summary_response) + .collect(), + }, + )) +} + pub async fn get_custom_world_agent_card_detail( State(state): State, Path((session_id, card_id)): Path<(String, String)>, @@ -1096,6 +1128,111 @@ fn spawn_custom_world_draft_foundation_job( } }; + let mut draft_profile_json = draft_result.draft_profile_json; + let mut draft_profile_value = match serde_json::from_str::(&draft_profile_json) { + Ok(Value::Object(object)) => Value::Object(object), + Ok(_) => { + let message = "foundation draft JSON 必须是 object".to_string(); + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "底稿素材生成失败", + message.as_str(), + 100, + Some(message), + ) + .await; + return; + } + Err(error) => { + let message = format!("foundation draft JSON 非法:{error}"); + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "底稿素材生成失败", + message.as_str(), + 100, + Some(message), + ) + .await; + return; + } + }; + + if let Err(message) = generate_draft_foundation_role_visuals( + &state, + &session, + &owner_user_id, + &operation_id, + &mut draft_profile_value, + ) + .await + { + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "生成角色主形象失败", + message.as_str(), + 100, + Some(message), + ) + .await; + return; + } + + if let Err(message) = generate_draft_foundation_act_backgrounds( + &state, + &session, + &owner_user_id, + &operation_id, + &mut draft_profile_value, + ) + .await + { + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "生成幕背景图失败", + message.as_str(), + 100, + Some(message), + ) + .await; + return; + } + + draft_profile_json = match serde_json::to_string(&draft_profile_value) { + Ok(value) => value, + Err(error) => { + let message = format!("带素材的 foundation draft JSON 序列化失败:{error}"); + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "底稿素材写回失败", + message.as_str(), + 100, + Some(message), + ) + .await; + return; + } + }; + let _ = upsert_custom_world_draft_foundation_progress( &state, &session.session_id, @@ -1109,34 +1246,32 @@ fn spawn_custom_world_draft_foundation_job( ) .await; - let payload_json = match build_draft_foundation_action_payload_json( - &payload, - &draft_result.draft_profile_json, - ) { - Ok(value) => value, - Err(error) => { - let message = match error { - DraftFoundationPayloadError::SerializePayload(message) => message, - DraftFoundationPayloadError::InvalidPayloadShape => { - "action payload 必须是 object".to_string() - } - DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message, - }; - let _ = upsert_custom_world_draft_foundation_progress( - &state, - &session.session_id, - &owner_user_id, - &operation_id, - "failed", - "底稿写入失败", - message.clone().as_str(), - 100, - Some(message), - ) - .await; - return; - } - }; + let payload_json = + match build_draft_foundation_action_payload_json(&payload, &draft_profile_json) { + Ok(value) => value, + Err(error) => { + let message = match error { + DraftFoundationPayloadError::SerializePayload(message) => message, + DraftFoundationPayloadError::InvalidPayloadShape => { + "action payload 必须是 object".to_string() + } + DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message, + }; + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "底稿写入失败", + message.clone().as_str(), + 100, + Some(message), + ) + .await; + return; + } + }; if let Err(error) = state .spacetime_client() @@ -1167,6 +1302,201 @@ fn spawn_custom_world_draft_foundation_job( }); } +async fn generate_draft_foundation_role_visuals( + state: &AppState, + session: &CustomWorldAgentSessionRecord, + owner_user_id: &str, + operation_id: &str, + draft_profile: &mut Value, +) -> Result<(), String> { + let Some(profile_object) = draft_profile.as_object_mut() else { + return Err("foundation draft JSON 必须是 object".to_string()); + }; + let mut role_refs = Vec::new(); + for key in ["playableNpcs", "storyNpcs"] { + if let Some(roles) = profile_object.get(key).and_then(Value::as_array) { + for index in 0..roles.len() { + role_refs.push((key.to_string(), index)); + } + } + } + let total = role_refs.len().max(1); + for (completed, (key, index)) in role_refs.into_iter().enumerate() { + let role = profile_object + .get(key.as_str()) + .and_then(Value::as_array) + .and_then(|roles| roles.get(index)) + .cloned() + .unwrap_or(Value::Null); + let name = + json_text_from_value(&role, "name").unwrap_or_else(|| format!("角色{}", index + 1)); + let role_id = json_text_from_value(&role, "id").unwrap_or_else(|| format!("{key}-{index}")); + let visual_prompt = json_text_from_value(&role, "visualDescription") + .or_else(|| json_text_from_value(&role, "description")) + .unwrap_or_else(|| name.clone()); + upsert_custom_world_draft_foundation_progress( + state, + &session.session_id, + owner_user_id, + operation_id, + "running", + "生成角色主形象", + format!("正在生成角色主形象 {}/{}:{}。", completed + 1, total, name).as_str(), + 97 + ((completed as u32).min(1)), + None, + ) + .await + .map_err(|error| error.to_string())?; + let generated = generate_character_primary_visual_for_profile( + state, + owner_user_id, + role_id.as_str(), + visual_prompt.as_str(), + Some(name.as_str()), + ) + .await + .map_err(|error| error.message().to_string())?; + if let Some(role_object) = profile_object + .get_mut(key.as_str()) + .and_then(Value::as_array_mut) + .and_then(|roles| roles.get_mut(index)) + .and_then(Value::as_object_mut) + { + role_object.insert("imageSrc".to_string(), Value::String(generated.image_src)); + role_object.insert( + "generatedVisualAssetId".to_string(), + Value::String(generated.asset_id), + ); + } + } + Ok(()) +} + +async fn generate_draft_foundation_act_backgrounds( + state: &AppState, + session: &CustomWorldAgentSessionRecord, + owner_user_id: &str, + operation_id: &str, + draft_profile: &mut Value, +) -> Result<(), String> { + let world_name = + json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); + let profile_id = json_text_from_value(draft_profile, "id"); + let act_refs = collect_scene_act_refs(draft_profile); + let total = act_refs.len().max(1); + for (completed, act_ref) in act_refs.into_iter().enumerate() { + upsert_custom_world_draft_foundation_progress( + state, + &session.session_id, + owner_user_id, + operation_id, + "running", + "生成幕背景图", + format!( + "正在生成幕背景图 {}/{}:{}。", + completed + 1, + total, + act_ref.title + ) + .as_str(), + 98, + None, + ) + .await + .map_err(|error| error.to_string())?; + let generated = generate_custom_world_scene_image_for_profile( + state, + owner_user_id, + profile_id.as_deref(), + world_name.as_str(), + act_ref.scene_id.as_str(), + act_ref.title.as_str(), + act_ref.summary.as_str(), + act_ref.prompt.as_str(), + ) + .await + .map_err(|error| error.message().to_string())?; + if let Some(act_object) = draft_profile + .get_mut("sceneChapterBlueprints") + .and_then(Value::as_array_mut) + .and_then(|chapters| chapters.get_mut(act_ref.chapter_index)) + .and_then(|chapter| chapter.get_mut("acts")) + .and_then(Value::as_array_mut) + .and_then(|acts| acts.get_mut(act_ref.act_index)) + .and_then(Value::as_object_mut) + { + act_object.insert( + "backgroundImageSrc".to_string(), + Value::String(generated.image_src), + ); + act_object.insert( + "backgroundAssetId".to_string(), + Value::String(generated.asset_id), + ); + act_object.insert( + "generatedScenePrompt".to_string(), + Value::String(generated.prompt), + ); + act_object.insert( + "generatedSceneModel".to_string(), + Value::String(generated.model), + ); + } + } + Ok(()) +} + +struct SceneActGenerationRef { + chapter_index: usize, + act_index: usize, + scene_id: String, + title: String, + summary: String, + prompt: String, +} + +fn collect_scene_act_refs(draft_profile: &Value) -> Vec { + draft_profile + .get("sceneChapterBlueprints") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .flat_map(|(chapter_index, chapter)| { + let chapter_scene_id = json_text_from_value(chapter, "sceneId") + .or_else(|| json_text_from_value(chapter, "id")) + .unwrap_or_else(|| format!("chapter-{chapter_index}")); + chapter + .get("acts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + .map(move |(act_index, act)| SceneActGenerationRef { + chapter_index, + 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_text_from_value(act, "backgroundPromptText") + .or_else(|| json_text_from_value(act, "summary")) + .unwrap_or_else(|| "场景幕背景图,突出探索空间与局势氛围。".to_string()), + }) + }) + .collect() +} + +fn json_text_from_value(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + async fn upsert_custom_world_draft_foundation_progress( state: &AppState, session_id: &str, diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 6da5d704..89fed8e1 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -122,6 +122,14 @@ struct GeneratedAssetResponse { actual_prompt: Option, } +#[derive(Clone, Debug)] +pub(crate) struct GeneratedCustomWorldSceneImage { + pub image_src: String, + pub asset_id: String, + pub prompt: String, + pub model: String, +} + struct PreparedAssetUpload { prefix: LegacyAssetPrefix, path_segments: Vec, @@ -537,6 +545,114 @@ pub async fn generate_custom_world_scene_image( Ok(json_success_body(Some(&request_context), asset)) } +pub(crate) async fn generate_custom_world_scene_image_for_profile( + state: &AppState, + owner_user_id: &str, + profile_id: Option<&str>, + world_name: &str, + scene_id: &str, + scene_name: &str, + scene_description: &str, + prompt_text: &str, +) -> Result { + let payload = CustomWorldSceneImageRequest { + profile_id: profile_id.map(ToOwned::to_owned), + world_name: Some(world_name.to_string()), + landmark_id: Some(scene_id.to_string()), + landmark_name: Some(scene_name.to_string()), + prompt: Some(prompt_text.to_string()), + size: Some("1600*900".to_string()), + negative_prompt: None, + reference_image_src: None, + user_prompt: Some(prompt_text.to_string()), + profile: Some(SceneImageProfileInput { + id: profile_id.map(ToOwned::to_owned), + name: Some(world_name.to_string()), + subtitle: None, + summary: None, + tone: None, + player_goal: None, + setting_text: None, + }), + landmark: Some(SceneImageLandmarkInput { + id: Some(scene_id.to_string()), + name: Some(scene_name.to_string()), + description: Some(scene_description.to_string()), + danger_level: None, + }), + }; + let normalized = normalize_scene_image_request(payload)?; + let settings = require_dashscope_settings(state)?; + let http_client = build_dashscope_http_client(&settings)?; + let generated = create_text_to_image_generation( + &http_client, + &settings, + TEXT_TO_IMAGE_SCENE_MODEL, + normalized.prompt.as_str(), + Some(normalized.negative_prompt.as_str()), + normalized.size.as_str(), + "创建场景图片生成任务失败", + "查询场景图片任务失败", + "场景图片生成任务失败", + "场景图片生成超时或未返回图片地址", + ) + .await?; + let downloaded = download_remote_image( + &http_client, + generated.image_url.as_str(), + "下载生成图片失败", + ) + .await?; + let asset_id = format!("custom-scene-{}", current_utc_millis()); + let upload = PreparedAssetUpload { + prefix: LegacyAssetPrefix::CustomWorldScenes, + path_segments: vec![ + sanitize_storage_segment( + normalized + .profile_id + .as_deref() + .unwrap_or(normalized.world_name.as_str()), + "world", + ), + sanitize_storage_segment(normalized.entity_id.as_str(), "scene"), + asset_id.clone(), + ], + file_name: format!("scene.{}", downloaded.extension), + content_type: downloaded.mime_type, + body: downloaded.bytes, + asset_kind: "scene_image", + entity_kind: "custom_world_landmark", + entity_id: normalized.entity_id.clone(), + profile_id: normalized.profile_id.clone(), + slot: "scene_image", + source_job_id: Some(generated.task_id.clone()), + }; + let model = normalized.model.clone(); + let prompt = normalized.prompt.clone(); + let asset = persist_custom_world_asset( + state, + owner_user_id, + upload, + GeneratedAssetResponse { + image_src: String::new(), + asset_id: asset_id.clone(), + source_type: "generated".to_string(), + model: Some(model.clone()), + size: Some(normalized.size), + task_id: Some(generated.task_id), + prompt: Some(prompt.clone()), + actual_prompt: generated.actual_prompt, + }, + ) + .await?; + Ok(GeneratedCustomWorldSceneImage { + image_src: asset.image_src, + asset_id, + prompt, + model, + }) +} + pub async fn generate_custom_world_cover_image( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index de063299..d6df6243 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -976,7 +976,8 @@ fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) - "3. coreConflicts 必须至少 1 条。".to_string(), "4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(), "5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(), - "6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(), + "6. sceneChapterBlueprints[*].acts[*].backgroundPromptText 必须逐幕生成,作为每一幕生成背景图时默认填入的场景画面描述,不要只生成一个全局场景背景提示词。".to_string(), + "7. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(), ] .join("\n\n") } @@ -1452,10 +1453,58 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue { "acts".to_string(), JsonValue::Array(vec![build_fallback_scene_act()]), ); + } else { + object.insert( + "acts".to_string(), + JsonValue::Array( + acts.into_iter() + .enumerate() + .map(|(index, act)| normalize_scene_act_blueprint(act, index)) + .collect(), + ), + ); } JsonValue::Object(object) } +fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue { + let mut object = act.as_object().cloned().unwrap_or_default(); + let fallback_act = build_fallback_scene_act_with_index(index); + let fallback_prompt = fallback_act + .get("backgroundPromptText") + .and_then(JsonValue::as_str) + .unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。") + .to_string(); + let title = object + .get("title") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("第{}幕", index + 1)); + let summary = object + .get("summary") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "当前幕推动场景内的主线压力。".to_string()); + object.insert("title".to_string(), JsonValue::String(title.clone())); + object.insert("summary".to_string(), JsonValue::String(summary.clone())); + let background_prompt = object + .get("backgroundPromptText") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("{title}:{summary}。{fallback_prompt}")); + object.insert( + "backgroundPromptText".to_string(), + JsonValue::String(background_prompt), + ); + JsonValue::Object(object) +} + fn build_fallback_scene_chapter_blueprint() -> JsonValue { json!({ "id": "chapter-act-1", @@ -1466,10 +1515,15 @@ fn build_fallback_scene_chapter_blueprint() -> JsonValue { } fn build_fallback_scene_act() -> JsonValue { + build_fallback_scene_act_with_index(0) +} + +fn build_fallback_scene_act_with_index(index: usize) -> JsonValue { json!({ - "id": "scene-act-1", - "title": "开场场景幕", + "id": format!("scene-act-{}", index + 1), + "title": if index == 0 { "开场场景幕".to_string() } else { format!("第{}幕", index + 1) }, "summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", + "backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。", }) } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index a094c8bd..267a0536 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -715,6 +715,42 @@ pub async fn put_puzzle_work( )) } +pub async fn delete_puzzle_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorksResponse { + items: items + .into_iter() + .map(map_puzzle_work_summary_response) + .collect(), + }, + )) +} + pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index 897664e8..c71d8166 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -276,6 +276,13 @@ pub struct BigFishWorksListInput { pub owner_user_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkDeleteInput { + pub session_id: String, + pub owner_user_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksProcedureResult { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 84cc74b0..3dc8cf13 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -372,6 +372,13 @@ pub struct PuzzleWorkGetInput { pub profile_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkUpsertInput { diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 70f88117..ee3cd538 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -320,6 +320,8 @@ pub struct CharacterAnimationPublishResponse { #[serde(rename_all = "camelCase")] pub struct CharacterWorkflowCachePayload { pub character_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_scope_id: Option, pub visual_prompt_text: String, pub animation_prompt_text: String, pub visual_drafts: Vec, @@ -342,6 +344,8 @@ pub struct CharacterWorkflowCachePayload { pub struct CharacterWorkflowCacheSaveRequest { pub character_id: String, #[serde(default)] + pub cache_scope_id: Option, + #[serde(default)] pub visual_prompt_text: Option, #[serde(default)] pub animation_prompt_text: Option, @@ -734,6 +738,7 @@ mod tests { ok: true, cache: CharacterWorkflowCachePayload { character_id: "hero".to_string(), + cache_scope_id: Some("world-01".to_string()), visual_prompt_text: "主形象".to_string(), animation_prompt_text: "待机".to_string(), visual_drafts: vec![CharacterVisualDraftPayload { @@ -758,6 +763,7 @@ mod tests { assert_eq!(payload["ok"], json!(true)); assert_eq!(payload["cache"]["characterId"], json!("hero")); + assert_eq!(payload["cache"]["cacheScopeId"], json!("world-01")); assert_eq!( payload["cache"]["visualDrafts"][0]["imageSrc"], json!("/generated-character-drafts/hero/visual/job/candidate.svg") diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 1c7d2003..a7931249 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -1,5 +1,6 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -71,6 +72,29 @@ impl SpacetimeClient { .await } + pub async fn delete_big_fish_work( + &self, + session_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishWorkDeleteInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .delete_big_fish_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_big_fish_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn submit_big_fish_message( &self, input: BigFishMessageSubmitRecordInput, diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 3f651ed5..b207320e 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -1,5 +1,6 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; impl SpacetimeClient { pub async fn list_custom_world_profiles( @@ -310,6 +311,29 @@ impl SpacetimeClient { .await } + pub async fn delete_custom_world_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = CustomWorldAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .delete_custom_world_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_works_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn get_custom_world_agent_card_detail( &self, session_id: String, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f8451a57..f5a49feb 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -4,7 +4,40 @@ pub mod module_bindings; mod mapper; pub(crate) use mapper::*; -pub use mapper::{BattleStateRecord, ResolveCombatActionRecord, CustomWorldLibraryEntryRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldPublishedProfileCompileRecord, CustomWorldPublishWorldRecord, CustomWorldAgentMessageRecord, CustomWorldAgentOperationRecord, CustomWorldDraftCardRecord, CustomWorldSupportedActionRecord, CustomWorldCheckpointRecord, CustomWorldAgentCheckpointRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldPublishGateRecord, CustomWorldWorkSummaryRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardDetailRecord, CustomWorldAgentSessionRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentActionExecuteRecord, PuzzleAgentSessionCreateRecordInput, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentMessageFinalizeRecordInput, PuzzleGeneratedImagesSaveRecordInput, PuzzleSelectCoverImageRecordInput, PuzzlePublishRecordInput, PuzzleWorkUpsertRecordInput, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleResultDraftRecord, PuzzleAgentMessageRecord, PuzzleAgentSuggestedActionRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleAgentSessionRecord, PuzzleWorkProfileRecord, PuzzleCellPositionRecord, PuzzlePieceStateRecord, PuzzleMergedGroupRecord, PuzzleBoardRecord, PuzzleRuntimeLevelRecord, PuzzleRunRecord, BigFishSessionCreateRecordInput, BigFishMessageSubmitRecordInput, BigFishMessageFinalizeRecordInput, BigFishAssetGenerateRecordInput, BigFishRunStartRecordInput, BigFishRunInputSubmitRecordInput, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishLevelBlueprintRecord, BigFishBackgroundBlueprintRecord, BigFishRuntimeParamsRecord, BigFishGameDraftRecord, BigFishAgentMessageRecord, BigFishAssetSlotRecord, BigFishAssetCoverageRecord, BigFishSessionRecord, BigFishWorkSummaryRecord, BigFishVector2Record, BigFishRuntimeEntityRecord, BigFishRuntimeRecord, ResolveNpcBattleInteractionInput, AiTaskStageRecord, AiResultReferenceRecord, AiTextChunkRecord, AiTaskRecord, AiTaskMutationRecord, NpcStateRecord, NpcInteractionRecord, NpcBattleInteractionRecord}; +pub use mapper::{ + AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, + AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, + BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, + BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord, + BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, + BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput, + BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord, + BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record, + BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, + CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, + CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, + CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, + CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, + NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, + PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, + PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, + PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, + PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput, + ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, +}; pub mod ai; pub mod assets; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_delete_input_type.rs new file mode 100644 index 00000000..c1c8f9d8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_delete_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkDeleteInput { + pub session_id: String, + pub owner_user_id: String, +} + + +impl __sdk::InModule for BigFishWorkDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs new file mode 100644 index 00000000..fc135445 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_big_fish_work_procedure.rs @@ -0,0 +1,57 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::big_fish_work_delete_input_type::BigFishWorkDeleteInput; +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct DeleteBigFishWorkArgs { + pub input: BigFishWorkDeleteInput, +} + + +impl __sdk::InModule for DeleteBigFishWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_big_fish_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_big_fish_work { + fn delete_big_fish_work(&self, input: BigFishWorkDeleteInput, +) { + self.delete_big_fish_work_then(input, |_, _| {}); + } + + fn delete_big_fish_work_then( + &self, + input: BigFishWorkDeleteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl delete_big_fish_work for super::RemoteProcedures { + fn delete_big_fish_work_then( + &self, + input: BigFishWorkDeleteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "delete_big_fish_work", + DeleteBigFishWorkArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs new file mode 100644 index 00000000..ed1eda1d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_custom_world_agent_session_procedure.rs @@ -0,0 +1,57 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInput; +use super::custom_world_works_list_result_type::CustomWorldWorksListResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct DeleteCustomWorldAgentSessionArgs { + pub input: CustomWorldAgentSessionGetInput, +} + + +impl __sdk::InModule for DeleteCustomWorldAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_custom_world_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_custom_world_agent_session { + fn delete_custom_world_agent_session(&self, input: CustomWorldAgentSessionGetInput, +) { + self.delete_custom_world_agent_session_then(input, |_, _| {}); + } + + fn delete_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionGetInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl delete_custom_world_agent_session for super::RemoteProcedures { + fn delete_custom_world_agent_session_then( + &self, + input: CustomWorldAgentSessionGetInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldWorksListResult>( + "delete_custom_world_agent_session", + DeleteCustomWorldAgentSessionArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs new file mode 100644 index 00000000..ae68be95 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_puzzle_work_procedure.rs @@ -0,0 +1,57 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_work_delete_input_type::PuzzleWorkDeleteInput; +use super::puzzle_works_procedure_result_type::PuzzleWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct DeletePuzzleWorkArgs { + pub input: PuzzleWorkDeleteInput, +} + + +impl __sdk::InModule for DeletePuzzleWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_puzzle_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_puzzle_work { + fn delete_puzzle_work(&self, input: PuzzleWorkDeleteInput, +) { + self.delete_puzzle_work_then(input, |_, _| {}); + } + + fn delete_puzzle_work_then( + &self, + input: PuzzleWorkDeleteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl delete_puzzle_work for super::RemoteProcedures { + fn delete_puzzle_work_then( + &self, + input: PuzzleWorkDeleteInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, PuzzleWorksProcedureResult>( + "delete_puzzle_work", + DeletePuzzleWorkArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index a26b146b..9c5fad25 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -94,6 +94,7 @@ pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; pub mod big_fish_vector_2_type; +pub mod big_fish_work_delete_input_type; pub mod big_fish_works_list_input_type; pub mod big_fish_works_procedure_result_type; pub mod chapter_pace_band_type; @@ -212,6 +213,7 @@ pub mod puzzle_run_start_input_type; pub mod puzzle_run_swap_input_type; pub mod puzzle_runtime_run_row_type; pub mod puzzle_select_cover_image_input_type; +pub mod puzzle_work_delete_input_type; pub mod puzzle_work_get_input_type; pub mod puzzle_work_procedure_result_type; pub mod puzzle_work_profile_row_type; @@ -404,7 +406,10 @@ pub mod create_battle_state_and_return_procedure; pub mod create_big_fish_session_procedure; pub mod create_custom_world_agent_session_procedure; pub mod create_puzzle_agent_session_procedure; +pub mod delete_big_fish_work_procedure; +pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_profile_and_return_procedure; +pub mod delete_puzzle_work_procedure; pub mod delete_runtime_snapshot_and_return_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod execute_custom_world_agent_action_procedure; @@ -559,6 +564,7 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; pub use big_fish_vector_2_type::BigFishVector2; +pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput; pub use big_fish_works_list_input_type::BigFishWorksListInput; pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use chapter_pace_band_type::ChapterPaceBand; @@ -677,6 +683,7 @@ pub use puzzle_run_start_input_type::PuzzleRunStartInput; pub use puzzle_run_swap_input_type::PuzzleRunSwapInput; pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow; pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput; +pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput; pub use puzzle_work_get_input_type::PuzzleWorkGetInput; pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_delete_input_type.rs new file mode 100644 index 00000000..2d9db44f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_delete_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + + +impl __sdk::InModule for PuzzleWorkDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 1b9c454e..4eaad1d8 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,5 +1,6 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; impl SpacetimeClient { pub async fn create_puzzle_agent_session( @@ -280,6 +281,29 @@ impl SpacetimeClient { .await } + pub async fn delete_puzzle_work( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = PuzzleWorkDeleteInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .delete_puzzle_work_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_works_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn list_puzzle_gallery( &self, ) -> Result, SpacetimeClientError> { diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 019f99b9..39454643 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -65,6 +65,32 @@ pub fn list_big_fish_works( } } +#[spacetimedb::procedure] +pub fn delete_big_fish_work( + ctx: &mut ProcedureContext, + input: BigFishWorkDeleteInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_big_fish_work_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_big_fish_message( ctx: &mut ProcedureContext, @@ -225,6 +251,69 @@ pub(crate) fn list_big_fish_works_tx( Ok(items) } +pub(crate) fn delete_big_fish_work_tx( + ctx: &ReducerContext, + input: BigFishWorkDeleteInput, +) -> Result, String> { + validate_session_get_input(&BigFishSessionGetInput { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + }) + .map_err(|error| error.to_string())?; + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + + // 删除作品时同步清理 Agent 消息、素材槽与运行快照,避免创作页消失后残留孤儿数据。 + ctx.db + .big_fish_creation_session() + .session_id() + .delete(&session.session_id); + for message in ctx + .db + .big_fish_agent_message() + .iter() + .filter(|row| row.session_id == input.session_id) + .collect::>() + { + ctx.db + .big_fish_agent_message() + .message_id() + .delete(&message.message_id); + } + for slot in ctx + .db + .big_fish_asset_slot() + .iter() + .filter(|row| row.session_id == input.session_id) + .collect::>() + { + ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id); + } + for run in ctx + .db + .big_fish_runtime_run() + .iter() + .filter(|row| { + row.session_id == input.session_id && row.owner_user_id == input.owner_user_id + }) + .collect::>() + { + ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id); + } + + list_big_fish_works_tx( + ctx, + BigFishWorksListInput { + owner_user_id: input.owner_user_id, + }, + ) +} + pub(crate) fn submit_big_fish_message_tx( ctx: &ReducerContext, input: BigFishMessageSubmitInput, diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index 176c4155..c367beeb 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -453,14 +453,22 @@ fn submit_custom_world_agent_message_tx( { return Err("custom_world_agent_message.message_id 已存在".to_string()); } - if ctx + if let Some(existing_operation) = ctx .db .custom_world_agent_operation() .operation_id() .find(&input.operation_id) - .is_some() { - return Err("custom_world_agent_operation.operation_id 已存在".to_string()); + let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation" + && existing_operation.session_id == input.session_id + && existing_operation.operation_type == RpgAgentOperationType::DraftFoundation + && matches!( + existing_operation.status, + RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running + ); + if !can_reuse_running_draft_operation { + return Err("custom_world_agent_operation.operation_id 已存在".to_string()); + } } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); @@ -829,6 +837,25 @@ pub fn list_custom_world_works( } } +#[spacetimedb::procedure] +pub fn delete_custom_world_agent_session( + ctx: &mut ProcedureContext, + input: CustomWorldAgentSessionGetInput, +) -> CustomWorldWorksListResult { + match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) { + Ok(items) => CustomWorldWorksListResult { + ok: true, + items, + error_message: None, + }, + Err(message) => CustomWorldWorksListResult { + ok: false, + items: Vec::new(), + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_custom_world_agent_card_detail( ctx: &mut ProcedureContext, @@ -1531,6 +1558,73 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn delete_custom_world_agent_session_tx( + ctx: &ReducerContext, + input: CustomWorldAgentSessionGetInput, +) -> Result, String> { + validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?; + + let session = ctx + .db + .custom_world_agent_session() + .session_id() + .find(&input.session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; + if session.stage == RpgAgentStage::Published { + return Err("已发布 RPG 作品请通过 profile 删除".to_string()); + } + + // 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。 + ctx.db + .custom_world_agent_session() + .session_id() + .delete(&session.session_id); + for message in ctx + .db + .custom_world_agent_message() + .iter() + .filter(|row| row.session_id == input.session_id) + .collect::>() + { + ctx.db + .custom_world_agent_message() + .message_id() + .delete(&message.message_id); + } + for operation in ctx + .db + .custom_world_agent_operation() + .iter() + .filter(|row| row.session_id == input.session_id) + .collect::>() + { + ctx.db + .custom_world_agent_operation() + .operation_id() + .delete(&operation.operation_id); + } + for card in ctx + .db + .custom_world_draft_card() + .iter() + .filter(|row| row.session_id == input.session_id) + .collect::>() + { + ctx.db + .custom_world_draft_card() + .card_id() + .delete(&card.card_id); + } + + list_custom_world_work_snapshots( + ctx, + CustomWorldWorksListInput { + owner_user_id: input.owner_user_id, + }, + ) +} + fn get_custom_world_agent_card_detail_tx( ctx: &ReducerContext, input: CustomWorldAgentCardDetailGetInput, @@ -1601,6 +1695,156 @@ fn execute_custom_world_agent_action_tx( } } +fn execute_generate_entities_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_refining_stage(session.stage, input.action.as_str())?; + + let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; + // 结果页只消费服务端 resultPreview,这里必须先写回草稿真相再刷新预览。 + let (payload_key, profile_key, card_kind, operation_type, entity_label) = + resolve_generate_entities_target(input.action.as_str(), payload)?; + let generated_entities = payload + .get(payload_key) + .and_then(JsonValue::as_array) + .cloned() + .ok_or_else(|| format!("{} requires payload.{payload_key}", input.action))?; + if generated_entities.is_empty() { + return Err(format!("{} requires at least one generated entity", input.action)); + } + + let mut appended_entities = Vec::new(); + for (index, entity) in generated_entities.into_iter().enumerate() { + let normalized_entity = ensure_generated_entity_id(entity, card_kind, index); + if normalized_entity.as_object().is_none() { + return Err(format!("{payload_key} entries must be objects")); + } + upsert_generated_entity_card( + ctx, + &session.session_id, + card_kind, + &normalized_entity, + input.submitted_at_micros, + )?; + appended_entities.push(normalized_entity); + } + + let entries = draft_profile + .entry(profile_key.to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| format!("draftProfile.{profile_key} must be array"))?; + entries.extend(appended_entities.iter().cloned()); + + let gate = summarize_publish_gate_from_json( + &session.session_id, + session.stage, + Some(&draft_profile), + &parse_json_array_or_empty(&session.quality_findings_json), + ); + let quality_findings = parse_json_array_or_empty(&session.quality_findings_json); + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object( + draft_profile.clone(), + ))?)), + last_assistant_reply: Some(Some(format!( + "已新增 {} 个{},并刷新结果预览。", + appended_entities.len(), + entity_label, + ))), + publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value( + &gate, + ))?)), + result_preview_json: Some(build_result_preview_json( + Some(&draft_profile), + &gate, + &quality_findings, + input.submitted_at_micros, + )?), + checkpoints_json: Some(append_checkpoint_json( + &session.checkpoints_json, + &build_session_checkpoint_value( + input.action.as_str(), + &format!("新增{}", entity_label), + session, + ), + )?), + updated_at_micros: Some(input.submitted_at_micros), + ..CustomWorldAgentSessionPatch::default() + }, + )?; + replace_custom_world_agent_session(ctx, session, next_session); + + append_custom_world_action_result_message( + ctx, + &session.session_id, + &input.operation_id, + &format!("已新增 {} 个{}。", appended_entities.len(), entity_label), + input.submitted_at_micros, + ); + + let operation = build_and_insert_custom_world_operation( + ctx, + &input.operation_id, + &session.session_id, + operation_type, + "新增内容已写入", + &format!( + "{} 已追加到 draftProfile.{},resultPreview 已刷新。", + entity_label, profile_key, + ), + input.submitted_at_micros, + ); + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn resolve_generate_entities_target( + action: &str, + payload: &JsonMap, +) -> Result<( + &'static str, + &'static str, + RpgAgentDraftCardKind, + RpgAgentOperationType, + &'static str, +), String> { + match action { + "generate_characters" => { + let profile_key = match payload.get("roleType").and_then(JsonValue::as_str).map(str::trim) { + Some("playable") => "playableNpcs", + _ => "storyNpcs", + }; + let entity_label = if profile_key == "playableNpcs" { + "可扮演角色" + } else { + "场景角色" + }; + Ok(( + "generatedCharacters", + profile_key, + RpgAgentDraftCardKind::Character, + RpgAgentOperationType::GenerateCharacters, + entity_label, + )) + } + "generate_landmarks" => Ok(( + "generatedLandmarks", + "landmarks", + RpgAgentDraftCardKind::Landmark, + RpgAgentOperationType::GenerateLandmarks, + "场景", + )), + other => Err(format!("custom world action `{other}` 当前尚未支持生成实体")), + } +} + fn execute_draft_foundation_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -1665,6 +1909,23 @@ fn execute_draft_foundation_action( updated_at, ); + if let Some(existing_operation) = ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&input.operation_id) + { + if existing_operation.session_id != session.session_id + || existing_operation.operation_type != RpgAgentOperationType::DraftFoundation + { + return Err("custom_world_agent_operation 与 draft_foundation 写回不匹配".to_string()); + } + ctx.db + .custom_world_agent_operation() + .operation_id() + .delete(&input.operation_id); + } + let operation = build_and_insert_custom_world_operation( ctx, &input.operation_id, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 5601a118..63efa1ca 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1497,7 +1497,8 @@ fn upsert_custom_world_agent_operation_progress_tx( ctx: &ReducerContext, input: CustomWorldAgentOperationProgressInput, ) -> Result { - validate_custom_world_agent_operation_progress_input(&input).map_err(|error| error.to_string())?; + validate_custom_world_agent_operation_progress_input(&input) + .map_err(|error| error.to_string())?; ctx.db .custom_world_agent_session() .session_id() @@ -1529,18 +1530,20 @@ fn upsert_custom_world_agent_operation_progress_tx( replace_custom_world_agent_operation(ctx, ¤t, next.clone()); next } else { - ctx.db.custom_world_agent_operation().insert(CustomWorldAgentOperation { - operation_id: input.operation_id.clone(), - session_id: input.session_id.clone(), - operation_type: input.operation_type, - status: input.operation_status, - phase_label: input.phase_label.clone(), - phase_detail: input.phase_detail.clone(), - progress: input.operation_progress, - error_message: input.error_message.clone(), - created_at: timestamp, - updated_at: timestamp, - }) + ctx.db + .custom_world_agent_operation() + .insert(CustomWorldAgentOperation { + operation_id: input.operation_id.clone(), + session_id: input.session_id.clone(), + operation_type: input.operation_type, + status: input.operation_status, + phase_label: input.phase_label.clone(), + phase_detail: input.phase_detail.clone(), + progress: input.operation_progress, + error_message: input.error_message.clone(), + created_at: timestamp, + updated_at: timestamp, + }) }; Ok(build_custom_world_agent_operation_snapshot(&operation)) diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 64d1b93d..ce285c7b 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -6,11 +6,12 @@ use module_puzzle::{ PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, - PuzzleSelectCoverImageInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, - PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, - apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, - compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, - publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces, + PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, + PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput, + PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, + build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack, + normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile, + start_run, swap_pieces, }; use serde_json::from_str as json_from_str; use serde_json::to_string as json_to_string; @@ -310,6 +311,25 @@ pub fn update_puzzle_work( } } +#[spacetimedb::procedure] +pub fn delete_puzzle_work( + ctx: &mut ProcedureContext, + input: PuzzleWorkDeleteInput, +) -> PuzzleWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) { + Ok(items) => PuzzleWorksProcedureResult { + ok: true, + items_json: Some(serialize_json(&items)), + error_message: None, + }, + Err(message) => PuzzleWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureResult { match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) { @@ -890,6 +910,65 @@ fn update_puzzle_work_tx( ) } +fn delete_puzzle_work_tx( + ctx: &TxContext, + input: PuzzleWorkDeleteInput, +) -> Result, String> { + let row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&input.profile_id) + .ok_or_else(|| "拼图作品不存在".to_string())?; + if row.owner_user_id != input.owner_user_id { + return Err("无权删除该拼图作品".to_string()); + } + + // 删除作品时同步清理来源 Agent 会话和运行快照,保持创作页列表与运行态数据一致。 + ctx.db + .puzzle_work_profile() + .profile_id() + .delete(&row.profile_id); + if let Some(session_id) = row.source_session_id.as_ref() { + if let Some(session) = ctx.db.puzzle_agent_session().session_id().find(session_id) { + ctx.db + .puzzle_agent_session() + .session_id() + .delete(&session.session_id); + } + for message in ctx + .db + .puzzle_agent_message() + .iter() + .filter(|message| message.session_id == *session_id) + .collect::>() + { + ctx.db + .puzzle_agent_message() + .message_id() + .delete(&message.message_id); + } + } + for run in ctx + .db + .puzzle_runtime_run() + .iter() + .filter(|run| { + run.owner_user_id == input.owner_user_id && run.entry_profile_id == input.profile_id + }) + .collect::>() + { + ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id); + } + + list_puzzle_works_tx( + ctx, + PuzzleWorksListInput { + owner_user_id: input.owner_user_id, + }, + ) +} + fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, String> { let mut items = ctx .db diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 4ee4cc4e..63cfe25d 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -18,7 +18,6 @@ import { resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; -import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { AnimationState, @@ -28,7 +27,6 @@ import { type SceneChapterBlueprint, } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; -import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { ResolvedAssetImage } from './ResolvedAssetImage'; @@ -54,8 +52,6 @@ interface CustomWorldEntityCatalogProps { onProfileChange: (profile: CustomWorldProfile) => void; onDeleteStoryNpcs?: (ids: string[]) => void; onDeleteLandmarks?: (ids: string[]) => void; - onGenerateRoleAssets?: (roleId: string) => void; - onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => void; createActionLabel?: string; onCreateAction?: () => void; createActionDisabled?: boolean; @@ -389,21 +385,21 @@ function CatalogCard({ tabIndex={disabled ? -1 : 0} onClick={disabled ? undefined : onClick} aria-disabled={disabled} - className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${ + className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${ isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel' }`} > -
+
{media}
-
+
-
+
{title}
@@ -411,7 +407,7 @@ function CatalogCard({ {selectionBadge}
-
+
{description || '暂无描述'}
{actions ?
{actions}
: null} @@ -891,8 +887,6 @@ export function CustomWorldEntityCatalog({ onProfileChange, onDeleteStoryNpcs, onDeleteLandmarks, - onGenerateRoleAssets, - onGenerateSceneAssets, createActionLabel, onCreateAction, createActionDisabled = false, @@ -1104,11 +1098,6 @@ export function CustomWorldEntityCatalog({ 1 + (pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0), } satisfies Record; - const coverPresentation = useMemo( - () => resolveCustomWorldCoverPresentation(profile), - [profile], - ); - const bulkDeleteTab: BulkDeleteTab | null = activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null; const isBulkDeleteMode = @@ -1185,28 +1174,28 @@ export function CustomWorldEntityCatalog({ return (
-
+
世界档案
-
+
{profile.name}
-
+
{profile.subtitle}
-
-
+
+
{RESULT_TABS.map((tab) => (
+ ) : null}
-
-
+
+
{title}
{subtitle}
-
+
{summary}
-
+
{isPuzzle ? ( <> @@ -188,11 +221,11 @@ export function CustomWorldWorkCard({ )}
-
+
@@ -200,21 +233,11 @@ export function CustomWorldWorkCard({ ) : null} - {onDelete ? ( - - ) : null}
diff --git a/src/components/custom-world-home/CustomWorldWorkTabs.tsx b/src/components/custom-world-home/CustomWorldWorkTabs.tsx index 328fd890..1b928cb7 100644 --- a/src/components/custom-world-home/CustomWorldWorkTabs.tsx +++ b/src/components/custom-world-home/CustomWorldWorkTabs.tsx @@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({ onChange, }: CustomWorldWorkTabsProps) { return ( -
+
{FILTER_OPTIONS.map((option) => { const count = option.id === 'draft' @@ -37,7 +37,7 @@ export function CustomWorldWorkTabs({ key={option.id} type="button" onClick={() => onChange(option.id)} - className={`platform-tab shrink-0 px-4 py-2 text-sm ${ + className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${ activeFilter === option.id ? 'platform-tab--active' : '' }`} > diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 00c2a6f2..baddeec2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence, motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import { lazy, Suspense, @@ -43,7 +43,10 @@ import { getBigFishCreationSession, streamBigFishCreationMessage, } from '../../services/big-fish-creation'; -import { listBigFishWorks } from '../../services/big-fish-works'; +import { + deleteBigFishWork, + listBigFishWorks, +} from '../../services/big-fish-works'; import { startBigFishRuntimeRun, submitBigFishRuntimeInput, @@ -63,9 +66,10 @@ import { startPuzzleRun, swapPuzzlePieces, } from '../../services/puzzle-runtime'; -import { listPuzzleWorks } from '../../services/puzzle-works'; +import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works'; import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry'; import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; +import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -1254,7 +1258,7 @@ export function PlatformEntryFlowShellImpl({ const handleDeletePublishedWork = useCallback( (work: (typeof creationHubItems)[number]) => { - if (!work.profileId || deletingCreationWorkId) { + if (deletingCreationWorkId) { return; } @@ -1265,18 +1269,22 @@ export function PlatformEntryFlowShellImpl({ if (!confirmed) { return; } - if (!work.profileId) { - platformBootstrap.setPlatformError('当前作品缺少 profileId,暂时无法删除。'); - return; - } - setDeletingCreationWorkId(work.workId); platformBootstrap.setPlatformError(null); - void deleteRpgEntryWorldProfile(work.profileId) - .then(async (entries) => { - platformBootstrap.setSavedCustomWorldEntries(entries); - await platformBootstrap.refreshCustomWorldWorks().catch(() => []); + const deleteTask = work.profileId + ? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap.refreshCustomWorldWorks().catch(() => []); + }) + : work.sessionId + ? deleteRpgCreationAgentSession(work.sessionId).then((items) => { + platformBootstrap.setCustomWorldWorkEntries(items); + }) + : Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。')); + + void deleteTask + .then(async () => { await platformBootstrap.refreshPublishedGallery().catch(() => []); }) .catch((error) => { @@ -1292,6 +1300,72 @@ export function PlatformEntryFlowShellImpl({ [deletingCreationWorkId, platformBootstrap, runProtectedAction], ); + const handleDeleteBigFishWork = useCallback( + (work: BigFishWorkSummary) => { + if (deletingCreationWorkId) { + return; + } + + runProtectedAction(() => { + const confirmed = window.confirm( + `确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`, + ); + if (!confirmed) { + return; + } + + setDeletingCreationWorkId(work.workId); + setBigFishError(null); + + void deleteBigFishWork(work.sourceSessionId) + .then((response) => { + setBigFishWorks(response.items); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }); + }, + [deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction], + ); + + const handleDeletePuzzleWork = useCallback( + (work: PuzzleWorkSummary) => { + if (deletingCreationWorkId) { + return; + } + + runProtectedAction(() => { + const confirmed = window.confirm( + `确认删除作品《${work.levelName}》吗?删除后会从你的作品列表和公开广场中移除。`, + ); + if (!confirmed) { + return; + } + + setDeletingCreationWorkId(work.workId); + setPuzzleError(null); + + void deletePuzzleWork(work.profileId) + .then((response) => { + setPuzzleWorks(response.items); + }) + .catch((error) => { + setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。')); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }); + }, + [deletingCreationWorkId, resolvePuzzleErrorMessage, runProtectedAction], + ); + const openPuzzleDetail = useCallback( async (profileId: string) => { setIsPuzzleBusy(true); @@ -1520,6 +1594,9 @@ export function PlatformEntryFlowShellImpl({ void startBigFishRunFromWork(item); }); }} + onDeleteBigFish={(item) => { + handleDeleteBigFishWork(item); + }} puzzleItems={puzzleWorks} onOpenPuzzleDetail={(item) => { runProtectedAction(() => { @@ -1535,6 +1612,9 @@ export function PlatformEntryFlowShellImpl({ void startPuzzleRunFromProfile(profileId); }); }} + onDeletePuzzle={(item) => { + handleDeletePuzzleWork(item); + }} /> ); @@ -2007,6 +2087,23 @@ export function PlatformEntryFlowShellImpl({ }); }); }} + onTestWorld={() => { + runProtectedAction(() => { + void enterWorldCoordinator + .enterWorldForTestFromCurrentResult() + .catch((error) => { + sessionController.setCustomWorldError( + resolveRpgCreationErrorMessage( + error, + '进入作品测试失败。', + ), + ); + }); + }); + }} + onPublishWorld={async () => { + await enterWorldCoordinator.publishCurrentResult(); + }} onGenerateEntity={ sessionController.isAgentDraftResultView ? async (kind) => { @@ -2061,49 +2158,6 @@ export function PlatformEntryFlowShellImpl({ } : undefined } - onGenerateRoleAssets={ - sessionController.isAgentDraftResultView - ? async (roleId) => { - const latestSession = - await autosaveCoordinator.executeAgentActionAndWait({ - action: 'generate_role_assets', - roleIds: [roleId], - }); - const latestProfile = latestSession - ? rpgCreationPreviewAdapter.buildPreviewFromSession( - latestSession, - ) - : null; - if (latestProfile) { - sessionController.setGeneratedCustomWorldProfile( - latestProfile, - ); - } - } - : undefined - } - onGenerateSceneAssets={ - sessionController.isAgentDraftResultView - ? async (sceneId, sceneKind) => { - const latestSession = - await autosaveCoordinator.executeAgentActionAndWait({ - action: 'generate_scene_assets', - sceneIds: [sceneId], - sceneKind, - }); - const latestProfile = latestSession - ? rpgCreationPreviewAdapter.buildPreviewFromSession( - latestSession, - ) - : null; - if (latestProfile) { - sessionController.setGeneratedCustomWorldProfile( - latestProfile, - ); - } - } - : undefined - } readOnly={false} compactAgentResultMode={ sessionController.isAgentDraftResultView diff --git a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx index daaf05da..8e16d0c9 100644 --- a/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx +++ b/src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx @@ -64,6 +64,7 @@ function buildDefaultAnimationPromptTextByKey(defaultText: string) { function pickCachedAnimationPromptTextByKey( cache: CharacterAssetWorkflowCache, fallbackText: string, + preferFreshRoleText: boolean, ) { const fromCache = cache.animationPromptTextByKey ?? {}; @@ -73,8 +74,9 @@ function pickCachedAnimationPromptTextByKey( const legacyText = cache.animationPromptText?.trim(); return { ...result, - [action.animation]: - cachedText && !isLegacyGeneratedActionDescription(cachedText) + [action.animation]: preferFreshRoleText + ? fallbackText + : cachedText && !isLegacyGeneratedActionDescription(cachedText) ? cachedText : legacyText && !isLegacyGeneratedActionDescription(legacyText) ? legacyText @@ -487,6 +489,7 @@ function buildAnimationPreviewCharacter(params: { export interface RpgCreationRoleAssetStudioModalProps { role: EditableCustomWorldRole; roleKind: 'playable' | 'story'; + cacheScopeId?: string; onApply?: (nextRole: EditableCustomWorldRole) => void; onPublishSuccess?: ( payload: { @@ -509,6 +512,7 @@ export interface RpgCreationRoleAssetStudioModalProps { export function RpgCreationRoleAssetStudioModal({ role, roleKind, + cacheScopeId, onApply, onPublishSuccess, onClose, @@ -746,13 +750,16 @@ export function RpgCreationRoleAssetStudioModal({ setSaveStatus(null); setIsHydratingCache(true); - void fetchCharacterWorkflowCache(baseRole.id) + void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId) .then((result) => { if (cancelled || !result.cache) { return; } const cache = result.cache; + if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) { + return; + } const nextRole = mergeRole(baseRole, { imageSrc: cache.imageSrc ?? baseRole.imageSrc, generatedVisualAssetId: @@ -765,7 +772,8 @@ export function RpgCreationRoleAssetStudioModal({ }); setWorkingRole(nextRole); setVisualPromptText( - cache.visualPromptText && + !baseRole.visualDescription?.trim() && + cache.visualPromptText && !isLegacyGeneratedVisualDescription(cache.visualPromptText) ? cache.visualPromptText : initialPromptBundle.visualPromptText, @@ -774,6 +782,7 @@ export function RpgCreationRoleAssetStudioModal({ pickCachedAnimationPromptTextByKey( cache, initialPromptBundle.animationPromptText, + Boolean(baseRole.actionDescription?.trim()), ), ); setVisualDrafts(cache.visualDrafts ?? []); @@ -798,7 +807,7 @@ export function RpgCreationRoleAssetStudioModal({ return () => { cancelled = true; }; - }, [baseRole, initialPromptBundle, roleSnapshotKey]); + }, [baseRole, cacheScopeId, initialPromptBundle, roleSnapshotKey]); useEffect(() => { if (isHydratingCache) { @@ -808,8 +817,10 @@ export function RpgCreationRoleAssetStudioModal({ const timer = window.setTimeout(() => { const payload: CharacterAssetWorkflowCache = { characterId: workingRole.id, + cacheScopeId, visualPromptText, animationPromptText, + animationPromptTextByKey, visualDrafts, selectedVisualDraftId, selectedAnimation, @@ -829,9 +840,11 @@ export function RpgCreationRoleAssetStudioModal({ }; }, [ animationPromptText, + animationPromptTextByKey, isHydratingCache, selectedAnimation, selectedVisualDraftId, + cacheScopeId, visualDrafts, visualPromptText, workingRole.animationMap, @@ -1137,7 +1150,12 @@ export function RpgCreationRoleAssetStudioModal({ workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId} workingRoleImageSrc={workingRole.imageSrc} workingRoleName={workingRole.name} - onAnimationPromptChange={setAnimationPromptText} + onAnimationPromptChange={(value) => { + setAnimationPromptTextByKey((current) => ({ + ...current, + [selectedAnimation]: value, + })); + }} onGenerateAnimation={() => { void handleGenerateAnimation(); }} diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index d9e40c61..1134b91c 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -202,6 +202,10 @@ function dedupeTextValues(values: Array) { ]; } +function compactTextList(values: Array) { + return values.map((value) => value?.trim() ?? '').filter(Boolean); +} + function moveArrayItem(values: T[], fromIndex: number, toIndex: number) { if ( fromIndex < 0 || @@ -326,19 +330,26 @@ function buildDefaultSceneActBlueprint(params: { const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1); const actTitle = buildDefaultSceneActTitle(params.index); const sceneLabel = params.sceneName.trim() || '当前场景'; + const sceneSummary = params.sceneSummary.trim(); const stageCoverage = buildSceneActStageCoverage(params.index, params.actCount); + const actSummary = + params.index === 0 + ? `玩家会在${sceneLabel}接住这一章的开场入口。` + : params.index >= params.actCount - 1 + ? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。` + : `${sceneLabel}的主要压力会在这一幕继续加深。`; return { id: `${params.sceneId}-act-${params.index + 1}`, sceneId: params.sceneId, title: actTitle, - summary: - params.index === 0 - ? `玩家会在${sceneLabel}接住这一章的开场入口。` - : params.index >= params.actCount - 1 - ? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。` - : `${sceneLabel}的主要压力会在这一幕继续加深。`, + summary: actSummary, stageCoverage, + backgroundPromptText: compactTextList([ + `${sceneLabel}${actTitle}背景`, + sceneSummary, + actSummary, + ]).join(';'), backgroundImageSrc: params.backgroundImageSrc || undefined, encounterNpcIds, primaryNpcId: encounterNpcIds[0] ?? '', @@ -461,6 +472,9 @@ function sanitizeSceneChapterBlueprint(params: { title: currentAct?.title?.trim() || fallbackAct.title, summary: currentAct?.summary?.trim() || fallbackAct.summary, stageCoverage: buildSceneActStageCoverage(index, targetActCount), + backgroundPromptText: + currentAct?.backgroundPromptText?.trim() || + fallbackAct.backgroundPromptText, backgroundImageSrc: currentAct?.backgroundImageSrc?.trim() || params.fallbackImageSrc || @@ -2391,15 +2405,18 @@ const FIXED_SCENE_IMAGE_SIZE = '1280*720'; function SceneImageGenerationModal({ profile, landmark, + initialPromptText, onApply, onClose, }: { profile: CustomWorldProfile; landmark: CustomWorldLandmark; + initialPromptText?: string; onApply: (result: CustomWorldSceneImageResult) => void; onClose: () => void; }) { const [userPrompt, setUserPrompt] = useDraft( + initialPromptText?.trim() || landmark.visualDescription?.trim() || landmark.description.trim() || landmark.name.trim(), @@ -2504,12 +2521,12 @@ function SceneImageGenerationModal({ -
+