From 67062a8af359fda0c0fe67ca570f51faf4111a28 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 22:25:13 +0800 Subject: [PATCH] 1 --- .../04_M5_CUSTOM_WORLD_AND_AGENT.md | 1 + ...WORK_CARD_DELETE_ICON_DESIGN_2026-04-24.md | 7 +- ...ND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md | 12 + ...CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md | 4 + docs/experience/README.md | 4 + ...ACT_BACKGROUND_PROMPT_SOURCE_2026-04-24.md | 26 + ...FT_IMAGE_PARALLEL_GENERATION_2026-04-24.md | 32 + ...OLE_VISUAL_DESCRIPTION_CHAIN_2026-04-24.md | 30 + ...R_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md | 24 +- ...ION_DRAFT_PERSISTENCE_DESIGN_2026-04-24.md | 45 ++ ...OM_WORLD_DRAFT_ASSET_PREVIEW_2026-04-24.md | 41 ++ ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 12 +- scripts/dev-rust-stack.sh | 41 +- .../api-server/src/ai_generation_drafts.rs | 233 +++++++ server-rs/crates/api-server/src/app.rs | 14 +- server-rs/crates/api-server/src/big_fish.rs | 68 +- .../crates/api-server/src/custom_world.rs | 606 ++++++++++++++---- .../src/custom_world_agent_entities.rs | 2 +- .../src/custom_world_foundation_draft.rs | 163 ++++- server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/puzzle.rs | 13 + .../spacetime-client/src/custom_world.rs | 35 + server-rs/crates/spacetime-client/src/lib.rs | 5 +- .../crates/spacetime-client/src/mapper.rs | 56 +- .../src/module_bindings/mod.rs | 4 + .../spacetime-module/src/custom_world/mod.rs | 29 +- src/components/CharacterAnimator.test.tsx | 15 + src/components/CharacterAnimator.tsx | 5 +- src/components/CustomWorldEntityCatalog.tsx | 48 ++ src/components/CustomWorldResultView.test.tsx | 35 +- src/components/auth/AuthGate.test.tsx | 62 +- src/components/auth/LoginScreen.tsx | 76 ++- ...ustomWorldCreationHub.interaction.test.tsx | 34 +- .../CustomWorldCreationHub.tsx | 5 +- .../custom-world-home/CustomWorldWorkCard.tsx | 47 +- .../PlatformEntryFlowShellImpl.tsx | 73 ++- .../RpgCreationEntityEditorShared.tsx | 11 +- .../RpgCreationResultActionBar.tsx | 1 + ...gEntryFlowShell.agent.interaction.test.tsx | 101 ++- src/data/customWorldLibrary.test.ts | 45 ++ src/data/customWorldLibrary.ts | 6 + src/prompts/customWorldPrompts.test.ts | 40 ++ src/prompts/customWorldPrompts.ts | 13 +- 43 files changed, 1857 insertions(+), 268 deletions(-) create mode 100644 docs/experience/RPG_ACT_BACKGROUND_PROMPT_SOURCE_2026-04-24.md create mode 100644 docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md create mode 100644 docs/experience/RPG_ROLE_VISUAL_DESCRIPTION_CHAIN_2026-04-24.md create mode 100644 docs/technical/AI_GENERATION_DRAFT_PERSISTENCE_DESIGN_2026-04-24.md create mode 100644 docs/technical/CUSTOM_WORLD_DRAFT_ASSET_PREVIEW_2026-04-24.md create mode 100644 server-rs/crates/api-server/src/ai_generation_drafts.rs create mode 100644 src/data/customWorldLibrary.test.ts create mode 100644 src/prompts/customWorldPrompts.test.ts diff --git a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md index 821973e6..5a62ebb7 100644 --- a/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md +++ b/backend-rewrite-tasklist/04_M5_CUSTOM_WORLD_AND_AGENT.md @@ -79,6 +79,7 @@ - [x] 兼容 `/api/runtime/custom-world/works` - [x] 兼容 `/api/runtime/custom-world/agent/sessions`(Stage 6 首批 Axum facade) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`(Stage 6 首批 Axum facade) +- [x] 兼容 `DELETE /api/runtime/custom-world/agent/sessions/:sessionId`(草稿物理清理;若作品卡误以已发布来源 session 删除,则回落到关联 profile 软删除并返回 works) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`(Stage 7 deterministic message submit) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`(Stage 8 SSE facade) - [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`(Stage 9 全量 action procedure 已接通) 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 index 77889b35..16e0ad73 100644 --- 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 @@ -6,7 +6,10 @@ ## 落地规则 -- 作品卡右上角固定展示删除 icon,底部主操作区只保留继续创作、查看详情、体验等正向操作。 +- 作品卡整体就是继续创作 / 继续完善 / 查看详情入口,不再在底部展示“继续完善”等重复主按钮。 +- 作品卡右上角固定展示删除 icon,底部主操作区只保留体验等必须独立触发的正向操作。 +- 点击作品卡任意非独立按钮区域都进入继续完善链路;点击删除或体验时不得冒泡触发作品卡打开。 +- 作品卡保留键盘可访问性:焦点落在卡片时按 Enter 或空格等同点击作品,焦点落在删除 / 体验按钮时只执行对应按钮动作。 - 删除入口不按发布状态隐藏:草稿、已发布作品均可删除。 - 删除入口不按玩法类型隐藏:RPG、大鱼吃小鱼、拼图作品均应在创作页可删除。 - 点击删除前保留浏览器确认弹窗,避免误触;删除中仅禁用当前作品卡的删除 icon。 @@ -18,3 +21,5 @@ - 大鱼作品按 `sourceSessionId` 删除创作 session,并同步清理消息、素材槽和运行快照。 - 拼图作品按 `profileId` 删除作品 profile,并同步清理来源 Agent session、消息和入口运行快照。 - RPG 已发布/持久草稿按 `profileId` 走既有自定义世界删除链路;纯 Agent session 草稿按 `sessionId` 走 owner-only session 删除过程,并清理消息、操作与草稿卡。 +- 自定义世界 Agent 的异步进度写回必须通过 `upsert_custom_world_agent_operation_progress` 过程落到 SpacetimeDB,`server-rs` 只做字符串入参与过程封装,不在 API 层维护额外进度状态。 +- `server-rs` 的删除路由使用 Axum 标准 `Path(sessionId)` 提取参数,并在进入 SpacetimeDB 前做 owner-only 与空值校验,避免 handler 签名和过程入参漂移。 diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 84606332..8eac1260 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -71,6 +71,7 @@ 弹窗内默认只保留: - 标题:`登录账号` +- 登录方式页签:`短信登录` / `密码登录` - 手机号输入框 - 验证码输入框 - 获取验证码按钮 @@ -88,6 +89,17 @@ - “先登录再同步进度”这类描述性文案 - 占据视觉主体的装饰信息块 +## 3.2.1 登录页签落地约束 + +账号面板需要把短信验证码登录和密码登录拆成互斥页签,避免两个登录表单在同一个面板里上下堆叠。 + +- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。 +- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。 +- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。 +- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。 +- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。 +- 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。 + ## 3.3 登录成功后的行为 - 手机号登录成功后,关闭弹窗 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 index ec1936ab..bb4fb97b 100644 --- a/docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md +++ b/docs/experience/PC_WORLD_CREATION_LAYOUT_OPTIMIZATION_2026-04-24.md @@ -24,4 +24,8 @@ - PC 端使用更明确的 `xl:grid`、固定信息侧栏和更小间距,让主内容首屏承载更多信息。 - 卡片在 PC 端降低无效高度,操作按钮与状态信息尽量同行展示。 +- 作品卡片底部统计标签必须保留在卡片圆角范围内,不能为了压缩高度让标签贴边或被 `overflow-hidden` 裁掉。 +- 卡片正文摘要优先缩短行数来给底部标签留空间;当标题、摘要或标签变长时,允许卡片自然增高。 +- RPG 作品卡片点击行为按作品状态分流:草稿统一继续创作,已发布作品进入详情或世界;不要只依赖 `sourceType` 判断草稿可打开性。 +- 整张作品卡片需要由卡片根节点承载点击与键盘打开能力,避免透明绝对定位按钮在真实浏览器中被判定不可见,导致自动化和用户点击不稳定。 - 保留现有 `platform-*` 视觉体系,避免引入新的 UI 系统。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 878fa35e..7257edf2 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -23,3 +23,7 @@ - 只需要读一份时,优先看 `PROJECT_WORK_EXPERIENCE_PLAYBOOK`。 - 做 UI 改动时,把本目录和根目录的 `UI_CODING_STANDARD.md` 对照着看。 - 做运行时流程改动时,把本目录和 `docs/audits/engineering/README.md` 一起看,能更快发现风险边界。 + +## 近期专项记录 + +- [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。 diff --git a/docs/experience/RPG_ACT_BACKGROUND_PROMPT_SOURCE_2026-04-24.md b/docs/experience/RPG_ACT_BACKGROUND_PROMPT_SOURCE_2026-04-24.md new file mode 100644 index 00000000..9e0552fb --- /dev/null +++ b/docs/experience/RPG_ACT_BACKGROUND_PROMPT_SOURCE_2026-04-24.md @@ -0,0 +1,26 @@ +# RPG 幕背景默认描述来源修正 2026-04-24 + +## 背景 + +草稿编辑器中“AI 生成幕背景”的“画面内容描述”曾出现类似“温馨员工宿舍第1幕背景;玩家入职后的首个落脚处;玩家会在温馨员工宿舍接住这一章的开场入口。”的默认文本。这类文本不是大模型直接写出的画面描述,而是前端或后端在缺少 `backgroundPromptText` 时,把地点名、幕标题、摘要规则句拼接出来的兜底文案。 + +## 落地约束 + +1. 幕背景图的默认画面描述必须来自草稿生成链路里的关键场景生成步骤,字段源为 `landmarks[*].actBackgroundPromptTexts[*]`。 +2. `sceneChapterBlueprints[*].acts[*].backgroundPromptText` 只承接上述幕级大模型产物,不再用 `title`、`summary`、地点描述或规则句拼接。 +3. 如果大模型漏产某一幕描述,后端规范化只保留空字符串,让后续生图前的 `backgroundPromptText` 校验暴露底稿质量问题,不能伪造可用默认文本。 +4. 前端编辑器 sanitize 只展示已有 `act.backgroundPromptText`;缺失时留空,不能在 UI 层重新拼接默认描述。 +5. 手动打开 AI 生成面板时,若字段为空,可由用户输入,但系统默认不替用户生成规则句。 + +## 当前实现 + +- `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` 的关键场景框架 prompt 要求 LLM 为每个地点生成 3 条 `actBackgroundPromptTexts`。 +- 草稿合成阶段通过 `build_scene_chapter_blueprints_from_landmarks` 把这些幕级描述写入 `sceneChapterBlueprints[*].acts[*].backgroundPromptText`。 +- `normalize_scene_act_blueprint` 不再把缺失描述补成“标题 + 摘要 + 通用场景背景”格式。 +- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` 不再用地点名、幕标题和 `actSummary` 生成 `backgroundPromptText` fallback。 + +## 验收要点 + +- 新草稿中每一幕的 `backgroundPromptText` 应该像自然的画面描述,包含主体、前中远景、站位空间、氛围识别点。 +- 不应再出现“第1幕背景;玩家会在……”这类明显拼接句。 +- 如果 LLM 漏掉 `actBackgroundPromptTexts`,生成幕背景图阶段应失败并提示缺少 `backgroundPromptText`,而不是静默使用拼接文案。 diff --git a/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md b/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md new file mode 100644 index 00000000..9c2c7136 --- /dev/null +++ b/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md @@ -0,0 +1,32 @@ +# RPG 底稿图片并行生成说明 2026-04-24 + +## 背景 + +RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景图都依赖同一份结构化底稿,但二者之间没有数据依赖。旧流程先生成所有角色主形象,再生成场景背景图,导致用户需要串行等待两类图片任务。 + +## 落地约束 + +1. 角色主形象与场景背景图必须在 API 编排层并行发起,且类内每个角色、每一幕背景也必须同时调用生图接口,不能只做到“角色大类”和“背景大类”并行。这里的场景背景图指 `sceneChapterBlueprints[*].acts[*]` 中每一幕的 `backgroundImageSrc`,不是世界封面图,也不是只按章节或地点生成一张图。 +2. SpacetimeDB reducer 只负责持久化操作进度和底稿写入,不承载外部 LLM / 图片生成调用。 +3. 生图前必须已经有文本设定:角色主形象使用角色对象的 `visualDescription`;幕背景图使用对应幕的 `backgroundPromptText`。缺字段时应中断并暴露底稿质量问题,不能退回 `description`、`summary` 或通用兜底词直接生图。 +4. 并行分支各自基于同一份底稿副本写入素材字段,完成后只合并背景图生成产物字段,避免覆盖角色图片字段或其他草稿内容。 +5. 单个大类失败仍按原有失败语义终止底稿写入,保留“生成角色主形象失败”和“生成幕背景图失败”的进度提示。 + +## 当前实现 + +- `server-rs/crates/api-server/src/custom_world.rs` 在 `spawn_custom_world_draft_foundation_job` 中使用 `tokio::join!` 同时执行: + - `generate_draft_foundation_role_visuals` + - `generate_draft_foundation_act_backgrounds` +- 角色分支使用 `JoinSet` 把所有角色主形象任务一次性投递,返回后再按角色位置写入 `imageSrc` 与 `generatedVisualAssetId`。 +- 背景分支使用 `JoinSet` 把 `sceneChapterBlueprints[*].acts[*]` 的每一幕背景任务一次性投递,返回后写入 `backgroundImageSrc`、`backgroundAssetId`、`generatedScenePrompt`、`generatedSceneModel`。 +- `merge_generated_act_backgrounds` 只把背景图字段合并回角色分支副本,再进入后续草稿卡编译和 SpacetimeDB 写入。 +- 幕背景 prompt 同时兼容 `backgroundPromptText`、`scenePromptText`、`visualPromptText`、`promptText`、`imagePromptText`、`backgroundPrompt`、`visualPrompt`,避免 LLM 输出字段别名导致整批背景图被误判缺失。 +- 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。任一单项超过 3 次仍失败时,后台任务必须把 operation 标记为 `failed` 并停止写入草稿卡,避免生成“缺主图 / 缺背景图”的可进入世界档案。 +- 中止前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId`。 +- 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。 + +## 后续注意 + +如果后续图片供应商出现强限流,再在网关层做队列或供应商侧限流;不要在 RPG 底稿编排层恢复逐张串行,否则会重新退化成多张图片总耗时累加。 + + diff --git a/docs/experience/RPG_ROLE_VISUAL_DESCRIPTION_CHAIN_2026-04-24.md b/docs/experience/RPG_ROLE_VISUAL_DESCRIPTION_CHAIN_2026-04-24.md new file mode 100644 index 00000000..9c3f0d2a --- /dev/null +++ b/docs/experience/RPG_ROLE_VISUAL_DESCRIPTION_CHAIN_2026-04-24.md @@ -0,0 +1,30 @@ +# RPG 角色形象描述数据链路核查 2026-04-24 + +## 结论 + +草稿生成阶段会让大模型为每个可扮演角色和场景角色生成 `visualDescription`,该字段是角色主形象生成和资产工坊“形象描述”输入框的同一份默认文本来源。 + +本次核查发现前端 `normalizeCustomWorldProfileRecord` 曾在规范化 `playableNpcs` / `storyNpcs` 时丢弃 `visualDescription`、`actionDescription`、`sceneVisualDescription`。因此后端草稿 JSON 中有大模型生成的文字,但草稿进入前端编辑器后,资产工坊可能只能回退到 `description`,用户看不到真正的角色形象文字描述。 + +## 数据链路 + +1. 后端草稿生成:`server-rs/crates/api-server/src/custom_world_foundation_draft.rs` + - 角色框架名单 prompt 要求 LLM 输出 `visualDescription`、`actionDescription`、`sceneVisualDescription`。 + - `visualDescription` 定义为打开角色形象图像生成面板时默认填入的角色形象描述。 +2. 后端角色主形象生成:`server-rs/crates/api-server/src/custom_world.rs` + - `generate_draft_foundation_role_visuals` 从角色对象读取 `visualDescription`。 + - 缺失时直接失败,提示不能在角色形象设定文本生成前生图。 + - 生图成功只写回 `imageSrc` 和 `generatedVisualAssetId`,不会覆盖 `visualDescription`。 +3. 草稿持久化:草稿 profile JSON 保留角色对象字段,`visualDescription` 应与图片字段一起进入保存载荷。 +4. 前端规范化:`src/data/customWorldLibrary.ts` + - `normalizePlayableNpc` / `normalizeStoryNpc` 必须保留三类资产描述字段。 +5. 资产工坊展示:`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx` + - modal 用角色对象构造 `baseRole`。 + - `buildDefaultRolePromptBundle(baseRole)` 优先把 `role.visualDescription` 转成 `visualPromptText`。 + - `RpgCreationRoleVisualSection` 的“形象描述” TextArea 展示 `visualPromptText`。 + +## 验收要点 + +- 草稿生成完毕后,打开某个角色的资产工坊,应在“形象描述”框看到 LLM 生成的 `visualDescription`。 +- 如果角色有 `visualDescription`,缓存中的旧 `visualPromptText` 不应覆盖它。 +- 如果角色缺 `visualDescription`,才允许前端回退到更弱的字段或缓存文本。 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md index a936102d..be0a52ff 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -361,10 +361,26 @@ 1. `id` 2. `name` 3. `role` -4. `publicMask` -5. `hiddenHook` -6. `relationToPlayer` -7. `summary` +4. `description` +5. `visualDescription` +6. `actionDescription` +7. `sceneVisualDescription` +8. `publicMask` +9. `hiddenHook` +10. `relationToPlayer` +11. `summary` + +### 角色资产工坊默认文本来源 + +`visualDescription` 是角色主形象生成入口的默认形象描述主源,必须在可扮演角色 / 场景角色的草稿生成步骤中跟随角色一并生成,不允许在资产工坊打开时再用本地规则把 `description`、`role`、`tags` 拼成默认文案。 + +生成要求: + +1. `description` 只写角色定位,控制在 8 到 18 个汉字内,用于角色卡摘要。 +2. `visualDescription` 专门写角色外观,包含轮廓、服饰 / 身体特征、携带物或材质气质,不写性格规则和玩法说明。 +3. `actionDescription` 专门写动作气质,用于动作生成默认文本。 +4. `sceneVisualDescription` 专门写角色常出现的场景氛围,用于场景图或角色场景联动默认文本。 +5. 资产工坊默认值优先读取 `visualDescription`,只有历史草稿缺失该字段时才允许回退到 `description`。 ### 插入规则 diff --git a/docs/technical/AI_GENERATION_DRAFT_PERSISTENCE_DESIGN_2026-04-24.md b/docs/technical/AI_GENERATION_DRAFT_PERSISTENCE_DESIGN_2026-04-24.md new file mode 100644 index 00000000..b33a6d50 --- /dev/null +++ b/docs/technical/AI_GENERATION_DRAFT_PERSISTENCE_DESIGN_2026-04-24.md @@ -0,0 +1,45 @@ +# AI 生成过程草稿持久化设计(2026-04-24) + +## 1. 背景 + +当前创作类模板已经具备 session / message / operation 级别的最终态落库能力,但部分流式生成只把模型增量推给前端。若 HTTP/SSE 连接、浏览器页面或 LLM 请求在最终解析前中断,用户只能看到短暂流式文本,服务端缺少可恢复的生成中间态。 + +本设计补齐“生成过程中已经生成的内容必须持续持久化”的机制,并要求该机制对所有创作模板统一生效。 + +## 2. 目标 + +1. 每次模板生成开始前创建或绑定一个 `ai_task`。 +2. 模型每次产出可见文本增量时,写入 `ai_text_chunk`,并同步更新 `ai_task.latest_text_output` 与对应 stage 的 `text_output`。 +3. 生成失败或连接中断时,不丢弃已经落库的 chunk;后续可用 `ai_task.latest_text_output` 作为续写上下文。 +4. 成功解析并 finalize 后,将最终结构化结果继续写回各模板原有 session 表,保持现有业务快照不变。 + +## 3. 统一落库边界 + +### 3.1 真相表 + +- `ai_task`:记录一次模板生成任务的业务来源、状态、最新聚合文本、结构化结果。 +- `ai_task_stage`:记录模板生成阶段状态;当前创作对话统一使用 `DraftGeneration`。 +- `ai_text_chunk`:按 `sequence` 追加保存模型增量文本,是断点恢复的最小粒度。 + +### 3.2 适用模板 + +- 自定义世界创作 Agent。 +- 解谜游戏创作 Agent。 +- 大鱼吃小鱼创作 Agent。 +- 后续新增模板必须复用同一生成草稿持久化工具,不允许只在 UI 内存保存流式文本。 + +## 4. 续写策略 + +1. 发起生成时,后端根据 `template_key + session_id + operation_id` 创建稳定 `task_id`。 +2. LLM 流式回调收到 `replyText` 的最新可见文本后,计算相对上一次文本的增量;只有非空增量写入 `ai_text_chunk`。 +3. 写入失败不应阻断当前生成主流程,但必须记录 warn 日志,避免因持久化瞬时失败导致用户生成直接失败。 +4. 若最终解析失败,`ai_task` 保持 `Running` 或显式 `Failed`,已写入的 `latest_text_output` 仍可作为下一轮 prompt 的“已生成草稿”。 +5. 下一轮续写 prompt 应优先带上最近未完成任务的 `latest_text_output`;本次先落地服务端 chunk 持久化能力,后续模板 prompt 可逐步消费该草稿。 + +## 5. 编码要求 + +1. 持久化逻辑放在 `server-rs/crates/api-server` 的通用工具中,由各模板路由接入。 +2. 不引入 `server-node` 兼容分支。 +3. SpacetimeDB 写入必须通过 `spacetime-client` 已生成绑定,不在 reducer 中访问网络或文件系统。 +4. 所有新增 Rust 代码保留中文注释,且只做局部修改,避免重写包含中文的大文件。 + diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_ASSET_PREVIEW_2026-04-24.md b/docs/technical/CUSTOM_WORLD_DRAFT_ASSET_PREVIEW_2026-04-24.md new file mode 100644 index 00000000..a5bf2a4a --- /dev/null +++ b/docs/technical/CUSTOM_WORLD_DRAFT_ASSET_PREVIEW_2026-04-24.md @@ -0,0 +1,41 @@ +# 世界草稿图片生成与预览补齐说明 + +更新时间:`2026-04-24` + +## 1. 检查结论 + +当前 server-rs 的世界底稿生成链路已经在 `draft_foundation` 后台任务中补齐两类图片: + +1. `playableNpcs` 与 `storyNpcs` 中的每个角色都会调用角色主形象生成链路,并把 `imageSrc`、`generatedVisualAssetId` 写回底稿。 +2. `sceneChapterBlueprints[].acts[]` 中的每一幕都会调用场景图生成链路,并把 `backgroundImageSrc`、`backgroundAssetId`、生成提示词与模型信息写回底稿。 + +图片生成后不落本地真值,而是通过 OSS `put_object -> head_object -> confirm_asset_object -> bind_asset_object_to_entity` 确认对象,并用兼容的 `/generated-*` 路径供前端读取。 + +## 2. 前端缺口 + +结果页的场景列表此前只把每个场景的第一张幕背景图作为场景卡封面。这样虽然后端已经生成了每一幕图片,但用户只能看到第一幕,无法在结果页确认同一场景下其他幕的图片是否存在。 + +## 3. 本次落地 + +1. 在 `CustomWorldEntityCatalog` 中增加每幕图片缩略条,来源为当前场景匹配到的 `sceneChapterBlueprints[].acts[].backgroundImageSrc`。 +2. 保留原来的场景卡封面策略:第一幕背景图仍作为主封面,旧的场景图字段继续作为兜底。 +3. 缩略条只展示已生成图片的幕,不额外暴露章节结构文本,避免结果页变成规则说明面板。 +4. 增加结果页测试,覆盖同一场景下两幕背景图都能在前端以图片形式预览。 + +## 4. 验收点 + +1. 生成世界草稿完成后,角色页签中所有可扮演角色和场景角色能展示 `imageSrc`。 +2. 场景页签中,每个场景卡片仍展示主封面。 +3. 场景卡片下方能横向预览该场景所有已生成幕背景图。 +4. OSS 未配置或上传失败时,后端任务应失败并把错误写入 operation,而不是生成伪本地路径。 + +## 5. 上游图片服务失败降级 + +`draft_foundation` 的底稿文本结构是进入结果页和继续编辑的主产物,角色主图、幕背景图属于可后补资产。若 DashScope 或 OSS 上游临时不可用,后台任务不应把整份底稿标记为失败。 + +本次补充后: + +1. 角色主图分支失败时,operation 记录错误信息并继续使用未带角色图的底稿。 +2. 幕背景图分支失败时,operation 记录错误信息并继续使用未带幕图的底稿。 +3. 已成功的并行资产分支仍会合并回底稿,不会被失败分支覆盖。 +4. 后续可通过资产工坊或单项生成动作补齐缺失图片。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index a2904310..30d307d1 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -37,8 +37,9 @@ npm run dev:rust 4. 等待 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 可用。 5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 6. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 -7. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 -8. 任一子进程退出时,脚本回收其余子进程。 +7. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 +8. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 +9. 任一子进程退出时,脚本回收其余子进程。 Vite 代理覆盖范围: @@ -84,6 +85,13 @@ npm run dev:rust:logs -- --follow 1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database//subscribe` 是否指向了未发布的库。 2. `spacetime --root-dir=server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`。 3. 发布库名与 `GENARRATIVE_SPACETIME_DATABASE` 不一致时,`/api/runtime/custom-world-gallery` 会从 Rust `api-server` 返回 `502`,前端首页只能展示空态或错误提示,无法自行修复。 +4. 如果 Vite 输出 `/api/auth/refresh`、`/api/auth/login-options` 或 `/api/runtime/custom-world-gallery` 的 `ECONNREFUSED`,先确认当前脚本是否已经打印 `等待 api-server 就绪` 并通过;正常情况下 Vite 只会在 `/healthz` 可访问后启动,不应再因为 Rust 监听未完成而代理失败。 + +编译警告治理: + +1. Rust 本地栈启动日志应保持可行动,运行态未使用函数不应长期保留为普通编译警告。 +2. 仅供测试断言使用的辅助函数使用 `#[cfg(test)]` 限定,避免进入 `cargo run -p api-server` 的普通二进制编译。 +3. 已无调用入口且无迁移价值的映射函数直接删除;如果后续新增同类 SpacetimeDB 记录映射,再按实际调用路径补回,避免提前保留死代码。 ## 3. Ubuntu 发布包脚本 diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 1e70dda8..e01b93bd 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env bash set -euo pipefail @@ -88,6 +88,39 @@ wait_for_spacetime() { exit 1 } +wait_for_api_server() { + local health_url="$1" + local timeout_seconds="$2" + local process_pid="${3:-}" + local deadline=$((SECONDS + timeout_seconds)) + + while ((SECONDS < deadline)); do + if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then + echo "[dev:rust] api-server 进程在就绪前退出。" >&2 + exit 1 + fi + + # 使用 Node 发起健康检查,避免要求 Windows 本地额外安装 curl/wget。 + if node -e ' +const target = process.argv[1]; +const client = target.startsWith("https:") ? require("https") : require("http"); +const request = client.get(target, { timeout: 1000 }, (response) => { + response.resume(); + process.exit(response.statusCode >= 200 && response.statusCode < 500 ? 0 : 1); +}); +request.on("timeout", () => request.destroy(new Error("timeout"))); +request.on("error", () => process.exit(1)); +' "${health_url}" >/dev/null 2>&1; then + return + fi + + sleep 0.5 + done + + echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2 + exit 1 +} + sync_local_spacetime_install() { local root_dir="$1" @@ -337,9 +370,13 @@ echo "[dev:rust] 启动 api-server" GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \ exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}" ) & -PIDS+=("$!") +API_PID="$!" +PIDS+=("${API_PID}") NAMES+=("api-server") +echo "[dev:rust] 等待 api-server 就绪" +wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${SPACETIME_TIMEOUT_SECONDS}" "${API_PID}" + echo "[dev:rust] 启动 vite" ( cd "${REPO_ROOT}" diff --git a/server-rs/crates/api-server/src/ai_generation_drafts.rs b/server-rs/crates/api-server/src/ai_generation_drafts.rs new file mode 100644 index 00000000..aa3ac4fd --- /dev/null +++ b/server-rs/crates/api-server/src/ai_generation_drafts.rs @@ -0,0 +1,233 @@ +use module_ai::{ + AiTaskCreateInput, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageStartInput, + AiTextChunkAppendInput, +}; +use serde_json::json; +use spacetime_client::{SpacetimeClient, SpacetimeClientError}; +use std::sync::{Arc, Mutex}; +use tracing::warn; + +#[derive(Clone, Debug)] +pub(crate) struct AiGenerationDraftContext { + pub task_id: String, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: String, + pub template_key: String, + pub operation_id: String, +} + +impl AiGenerationDraftContext { + pub fn new( + template_key: &str, + owner_user_id: &str, + session_id: &str, + operation_id: &str, + request_label: &str, + ) -> Self { + let normalized_template = normalize_identifier_segment(template_key); + let normalized_session = normalize_identifier_segment(session_id); + let normalized_operation = normalize_identifier_segment(operation_id); + + Self { + // 生成过程草稿使用稳定 task_id,保证同一模板会话操作重试时能继续定位已有内容。 + task_id: format!( + "aitask_draft_{normalized_template}_{normalized_session}_{normalized_operation}" + ), + owner_user_id: owner_user_id.trim().to_string(), + request_label: request_label.trim().to_string(), + source_module: normalized_template, + source_entity_id: session_id.trim().to_string(), + template_key: template_key.trim().to_string(), + operation_id: operation_id.trim().to_string(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct AiGenerationDraftSink { + context: AiGenerationDraftContext, + client: SpacetimeClient, + next_sequence: Arc>, + persisted_text: Arc>, +} + +impl AiGenerationDraftSink { + pub fn new(context: AiGenerationDraftContext, client: SpacetimeClient) -> Self { + Self { + context, + client, + next_sequence: Arc::new(Mutex::new(1)), + persisted_text: Arc::new(Mutex::new(String::new())), + } + } + + pub fn persist_visible_text_async(&self, visible_text: &str) { + let (sequence, delta_text) = { + let mut persisted_text = self + .persisted_text + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let delta_text = visible_text + .strip_prefix(persisted_text.as_str()) + .unwrap_or(visible_text) + .to_string(); + *persisted_text = visible_text.to_string(); + if delta_text.trim().is_empty() { + return; + } + + let mut next_sequence = self + .next_sequence + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let sequence = *next_sequence; + *next_sequence = next_sequence.saturating_add(1); + (sequence, delta_text) + }; + let context = self.context.clone(); + let client = self.client.clone(); + tokio::spawn(async move { + if let Err(error) = client + .append_ai_text_chunk(AiTextChunkAppendInput { + task_id: context.task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + sequence, + delta_text, + created_at_micros: current_utc_micros(), + }) + .await + { + warn!( + task_id = %context.task_id, + sequence, + error = %error, + "AI 生成草稿后台增量落库失败,主生成流程继续执行" + ); + } + }); + } +} + +#[derive(Debug)] +pub(crate) struct AiGenerationDraftWriter { + context: AiGenerationDraftContext, + next_sequence: u32, + persisted_text: String, +} + +impl AiGenerationDraftWriter { + pub fn new(context: AiGenerationDraftContext) -> Self { + Self { + context, + next_sequence: 1, + persisted_text: String::new(), + } + } + + pub async fn ensure_started( + &mut self, + client: &SpacetimeClient, + ) -> Result<(), SpacetimeClientError> { + let now_micros = current_utc_micros(); + match client + .create_ai_task(AiTaskCreateInput { + task_id: self.context.task_id.clone(), + task_kind: AiTaskKind::CustomWorldGeneration, + owner_user_id: self.context.owner_user_id.clone(), + request_label: self.context.request_label.clone(), + source_module: self.context.source_module.clone(), + source_entity_id: Some(self.context.source_entity_id.clone()), + request_payload_json: Some( + json!({ + "templateKey": self.context.template_key, + "operationId": self.context.operation_id, + }) + .to_string(), + ), + stages: vec![AiTaskStageBlueprint { + stage_kind: AiTaskStageKind::RequestModel, + label: "请求模型".to_string(), + detail: "模板生成过程中持续写入模型已生成文本。".to_string(), + order: 1, + }], + created_at_micros: now_micros, + }) + .await + { + Ok(_) => {} + Err(error) if is_duplicate_ai_task_error(&error) => {} + Err(error) => return Err(error), + } + + client + .start_ai_task_stage(AiTaskStageStartInput { + task_id: self.context.task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + started_at_micros: now_micros, + }) + .await + } + + pub async fn persist_visible_text(&mut self, client: &SpacetimeClient, visible_text: &str) { + let delta_text = match visible_text.strip_prefix(self.persisted_text.as_str()) { + Some(delta) => delta, + None => visible_text, + }; + if delta_text.trim().is_empty() { + self.persisted_text = visible_text.to_string(); + return; + } + + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.persisted_text = visible_text.to_string(); + + if let Err(error) = client + .append_ai_text_chunk(AiTextChunkAppendInput { + task_id: self.context.task_id.clone(), + stage_kind: AiTaskStageKind::RequestModel, + sequence, + delta_text: delta_text.to_string(), + created_at_micros: current_utc_micros(), + }) + .await + { + warn!( + task_id = %self.context.task_id, + sequence, + error = %error, + "AI 生成草稿增量落库失败,主生成流程继续执行" + ); + } + } +} + +fn normalize_identifier_segment(value: &str) -> String { + let normalized = value + .trim() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' || character == '_' { + character + } else { + '_' + } + }) + .collect::(); + + if normalized.is_empty() { + "unknown".to_string() + } else { + normalized + } +} + +fn is_duplicate_ai_task_error(error: &SpacetimeClientError) -> bool { + error.to_string().contains("ai_task.task_id 已存在") +} + +fn current_utc_micros() -> i64 { + time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000 +} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 9beca63a..7e19a372 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -48,13 +48,13 @@ use crate::{ custom_world::{ 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, - list_custom_world_gallery, publish_custom_world_library_profile, - put_custom_world_library_profile, stream_custom_world_agent_message, - submit_custom_world_agent_message, unpublish_custom_world_library_profile, + 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, list_custom_world_gallery, + publish_custom_world_library_profile, put_custom_world_library_profile, + stream_custom_world_agent_message, submit_custom_world_agent_message, + unpublish_custom_world_library_profile, }, custom_world_ai::{ generate_custom_world_cover_image, generate_custom_world_entity, diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 3873195e..c7c6a950 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -42,8 +42,14 @@ use crate::big_fish_agent_turn::{ run_big_fish_agent_turn, }; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + ai_generation_drafts::{ + AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, + }, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + http_error::AppError, + request_context::RequestContext, + state::AppState, }; pub async fn create_big_fish_session( @@ -203,14 +209,44 @@ pub async fn submit_big_fish_message( .map_err(|error| { big_fish_error_response(&request_context, map_big_fish_client_error(error)) })?; + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "big_fish", + owner_user_id.as_str(), + session_id.as_str(), + payload.client_message_id.as_str(), + "大鱼吃小鱼模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行"); + } + let draft_sink = AiGenerationDraftSink::new( + AiGenerationDraftContext::new( + "big_fish", + owner_user_id.as_str(), + session_id.as_str(), + payload.client_message_id.as_str(), + "大鱼吃小鱼模板生成草稿", + ), + state.spacetime_client().clone(), + ); let turn_result = run_big_fish_agent_turn( BigFishAgentTurnRequest { llm_client: state.llm_client(), session: &submitted_session, }, - |_| {}, + move |text| { + draft_sink.persist_visible_text_async(text); + }, ) .await; + if let Ok(result) = &turn_result { + draft_writer + .persist_visible_text( + state.spacetime_client(), + result.assistant_reply_text.as_str(), + ) + .await; + } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id.clone(), @@ -285,6 +321,26 @@ pub async fn stream_big_fish_message( .map_err(|error| { big_fish_error_response(&request_context, map_big_fish_client_error(error)) })?; + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "big_fish", + owner_user_id.as_str(), + session_id.as_str(), + payload.client_message_id.as_str(), + "大鱼吃小鱼模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行"); + } + let draft_sink = AiGenerationDraftSink::new( + AiGenerationDraftContext::new( + "big_fish", + owner_user_id.as_str(), + session_id.as_str(), + payload.client_message_id.as_str(), + "大鱼吃小鱼模板生成草稿", + ), + state.spacetime_client().clone(), + ); let mut streamed_reply_text = String::new(); let turn_result = run_big_fish_agent_turn( BigFishAgentTurnRequest { @@ -292,10 +348,16 @@ pub async fn stream_big_fish_message( session: &submitted_session, }, |text| { + draft_sink.persist_visible_text_async(text); streamed_reply_text = text.to_string(); }, ) .await; + if !streamed_reply_text.is_empty() { + draft_writer + .persist_visible_text(state.spacetime_client(), streamed_reply_text.as_str()) + .await; + } let reply_text = match &turn_result { Ok(result) => result.assistant_reply_text.clone(), Err(error) => error.to_string(), diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index b1af2a44..42f391ba 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -28,9 +28,10 @@ use shared_contracts::runtime::{ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, - CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, - CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, @@ -38,9 +39,13 @@ use spacetime_client::{ CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::convert::Infallible; +use tokio::task::JoinSet; use tracing::info; use crate::{ + ai_generation_drafts::{ + AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter, + }, api_response::json_success_body, auth::AuthenticatedAccessToken, character_visual_assets::generate_character_primary_visual_for_profile, @@ -59,6 +64,8 @@ use crate::{ state::AppState, }; +const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; + pub async fn get_custom_world_library( State(state): State, Extension(request_context): Extension, @@ -508,7 +515,7 @@ pub async fn get_custom_world_works( pub async fn delete_custom_world_agent_session( State(state): State, - AxumPath(session_id): AxumPath, + Path(session_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { @@ -516,10 +523,7 @@ pub async fn delete_custom_world_agent_session( let items = state .spacetime_client() - .delete_custom_world_agent_session( - session_id, - authenticated.claims().user_id().to_string(), - ) + .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)) @@ -636,6 +640,26 @@ pub async fn submit_custom_world_agent_message( .map_err(|error| { custom_world_error_response(&request_context, map_custom_world_client_error(error)) })?; + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "custom_world", + owner_user_id.as_str(), + session_id.as_str(), + operation_id.as_str(), + "自定义世界模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行"); + } + let draft_sink = AiGenerationDraftSink::new( + AiGenerationDraftContext::new( + "custom_world", + owner_user_id.as_str(), + session_id.as_str(), + operation_id.as_str(), + "自定义世界模板生成草稿", + ), + state.spacetime_client().clone(), + ); let turn_result = run_custom_world_agent_turn( CustomWorldAgentTurnRequest { llm_client: state.llm_client(), @@ -643,9 +667,19 @@ pub async fn submit_custom_world_agent_message( quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), focus_card_id: payload.focus_card_id.clone(), }, - |_| {}, + move |text| { + draft_sink.persist_visible_text_async(text); + }, ) .await; + if let Ok(result) = &turn_result { + draft_writer + .persist_visible_text( + state.spacetime_client(), + result.assistant_reply_text.as_str(), + ) + .await; + } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( session_id.clone(), @@ -764,6 +798,26 @@ pub async fn stream_custom_world_agent_message( let owner_user_id_for_stream = owner_user_id.clone(); let operation_id = operation.operation_id.clone(); let stream = async_stream::stream! { + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "custom_world", + owner_user_id_for_stream.as_str(), + session_id_for_stream.as_str(), + operation_id.as_str(), + "自定义世界模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行"); + } + let draft_sink = AiGenerationDraftSink::new( + AiGenerationDraftContext::new( + "custom_world", + owner_user_id_for_stream.as_str(), + session_id_for_stream.as_str(), + operation_id.as_str(), + "自定义世界模板生成草稿", + ), + state.spacetime_client().clone(), + ); // 聊天回复必须等本轮模型解析、进度与会话快照全部落库后, // 再随最终 session 一次性返回,避免玩家先看到回复而进度仍停在旧状态。 let turn_result = run_custom_world_agent_turn( @@ -773,9 +827,16 @@ pub async fn stream_custom_world_agent_message( quick_fill_requested, focus_card_id, }, - |_| {}, + move |text| { + draft_sink.persist_visible_text_async(text); + }, ) .await; + if let Ok(result) = &turn_result { + draft_writer + .persist_visible_text(state.spacetime_client(), result.assistant_reply_text.as_str()) + .await; + } let finalize_input = match turn_result { Ok(turn_result) => build_finalize_record_input( @@ -1121,7 +1182,7 @@ fn spawn_custom_world_draft_foundation_job( "底稿生成失败", message.clone().as_str(), 100, - Some(message), + Some(message.clone()), ) .await; return; @@ -1142,7 +1203,7 @@ fn spawn_custom_world_draft_foundation_job( "底稿素材生成失败", message.as_str(), 100, - Some(message), + Some(message.clone()), ) .await; return; @@ -1158,56 +1219,73 @@ fn spawn_custom_world_draft_foundation_job( "底稿素材生成失败", message.as_str(), 100, - Some(message), + Some(message.clone()), ) .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; - } + let role_visual_profile_input = draft_profile_value.clone(); + let act_background_profile_input = draft_profile_value.clone(); + // 角色主形象与幕背景图互不依赖,必须并行发起,避免底稿阶段串行等待两类图片。 + let (role_visual_result, act_background_result) = tokio::join!( + async { + let mut profile = role_visual_profile_input; + generate_draft_foundation_role_visuals( + &state, + &session, + &owner_user_id, + &operation_id, + &mut profile, + ) + .await + .map(|_| profile) + }, + async { + let mut profile = act_background_profile_input; + generate_draft_foundation_act_backgrounds( + &state, + &session, + &owner_user_id, + &operation_id, + &mut profile, + ) + .await + .map(|_| profile) + } + ); - 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( + let mut draft_profile_with_assets = draft_profile_value.clone(); + let mut asset_generation_errors = Vec::new(); + match role_visual_result { + Ok(profile) => draft_profile_with_assets = profile, + Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)), + } + match act_background_result { + Ok(profile) => merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile), + Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)), + } + draft_profile_value = draft_profile_with_assets; + + if !asset_generation_errors.is_empty() { + let message = asset_generation_errors + .iter() + .map(|(_, message)| message.as_str()) + .collect::>() + .join(";"); + let phase_label = asset_generation_errors + .first() + .map(|(label, _)| *label) + .unwrap_or("素材生成失败"); + persist_partial_draft_foundation_after_asset_failure( &state, - &session.session_id, + &session, &owner_user_id, &operation_id, - "failed", - "生成幕背景图失败", + &draft_profile_value, + phase_label, message.as_str(), - 100, - Some(message), ) .await; return; @@ -1226,7 +1304,7 @@ fn spawn_custom_world_draft_foundation_job( "底稿素材写回失败", message.as_str(), 100, - Some(message), + Some(message.clone()), ) .await; return; @@ -1320,8 +1398,8 @@ async fn generate_draft_foundation_role_visuals( } } } - let total = role_refs.len().max(1); - for (completed, (key, index)) in role_refs.into_iter().enumerate() { + let mut role_generation_refs = Vec::new(); + for (key, index) in role_refs { let role = profile_object .get(key.as_str()) .and_then(Value::as_array) @@ -1331,31 +1409,82 @@ async fn generate_draft_foundation_role_visuals( 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())?; + let visual_prompt = json_text_from_value(&role, "visualDescription").ok_or_else(|| { + format!("角色「{name}」缺少 visualDescription,不能在角色形象设定文本生成前直接生图。") + })?; + role_generation_refs.push(RoleVisualGenerationRef { + key, + index, + role_id, + name, + prompt: visual_prompt, + }); + } + + upsert_custom_world_draft_foundation_progress( + state, + &session.session_id, + owner_user_id, + operation_id, + "running", + "并行生成角色主形象", + format!("正在同时生成 {} 张角色主形象。", role_generation_refs.len()).as_str(), + 97, + None, + ) + .await + .map_err(|error| error.to_string())?; + + let mut generation_tasks = JoinSet::new(); + for role_ref in role_generation_refs { + let task_state = (*state).clone(); + let task_owner_user_id = owner_user_id.to_string(); + generation_tasks.spawn(async move { + let mut last_error = None; + for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { + match generate_character_primary_visual_for_profile( + &task_state, + task_owner_user_id.as_str(), + role_ref.role_id.as_str(), + role_ref.prompt.as_str(), + Some(role_ref.name.as_str()), + ) + .await + { + Ok(generated) => { + return Ok::<_, String>((role_ref.key, role_ref.index, generated)); + } + Err(error) => { + last_error = Some(error.message().to_string()); + if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis( + 300 * u64::from(attempt), + )) + .await; + } + } + } + } + + Err(format!( + "角色「{}」主形象连续生成 {} 次失败:{}", + role_ref.name, + DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, + last_error.unwrap_or_else(|| "未知错误".to_string()) + )) + }); + } + + let mut errors = Vec::new(); + while let Some(result) = generation_tasks.join_next().await { + let task_result = result.map_err(|error| error.to_string())?; + let (key, index, generated) = match task_result { + Ok(value) => value, + Err(message) => { + errors.push(message); + continue; + } + }; if let Some(role_object) = profile_object .get_mut(key.as_str()) .and_then(Value::as_array_mut) @@ -1369,6 +1498,9 @@ async fn generate_draft_foundation_role_visuals( ); } } + if !errors.is_empty() { + return Err(errors.join(";")); + } Ok(()) } @@ -1383,46 +1515,87 @@ async fn generate_draft_foundation_act_backgrounds( 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())?; + validate_scene_act_background_prompts(&act_refs)?; + upsert_custom_world_draft_foundation_progress( + state, + &session.session_id, + owner_user_id, + operation_id, + "running", + "并行生成幕背景图", + format!("正在同时生成 {} 张幕背景图。", act_refs.len()).as_str(), + 98, + None, + ) + .await + .map_err(|error| error.to_string())?; + + let mut generation_tasks = JoinSet::new(); + for act_ref in act_refs { + let task_state = (*state).clone(); + let task_owner_user_id = owner_user_id.to_string(); + let task_profile_id = profile_id.clone(); + let task_world_name = world_name.clone(); + generation_tasks.spawn(async move { + let mut last_error = None; + for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { + match generate_custom_world_scene_image_for_profile( + &task_state, + task_owner_user_id.as_str(), + task_profile_id.as_deref(), + task_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 + { + Ok(generated) => { + return Ok::<_, String>(( + act_ref.chapter_index, + act_ref.act_index, + generated, + )); + } + Err(error) => { + last_error = Some(error.message().to_string()); + if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis( + 300 * u64::from(attempt), + )) + .await; + } + } + } + } + + Err(format!( + "幕「{}」背景图连续生成 {} 次失败:{}", + act_ref.title, + DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, + last_error.unwrap_or_else(|| "未知错误".to_string()) + )) + }); + } + + let mut errors = Vec::new(); + while let Some(result) = generation_tasks.join_next().await { + let task_result = result.map_err(|error| error.to_string())?; + let (chapter_index, act_index, generated) = match task_result { + Ok(value) => value, + Err(message) => { + errors.push(message); + continue; + } + }; 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(|chapters| chapters.get_mut(chapter_index)) .and_then(|chapter| chapter.get_mut("acts")) .and_then(Value::as_array_mut) - .and_then(|acts| acts.get_mut(act_ref.act_index)) + .and_then(|acts| acts.get_mut(act_index)) .and_then(Value::as_object_mut) { act_object.insert( @@ -1443,9 +1616,20 @@ async fn generate_draft_foundation_act_backgrounds( ); } } + if !errors.is_empty() { + return Err(errors.join(";")); + } Ok(()) } +struct RoleVisualGenerationRef { + key: String, + index: usize, + role_id: String, + name: String, + prompt: String, +} + struct SceneActGenerationRef { chapter_index: usize, act_index: usize, @@ -1480,14 +1664,93 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec { 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()), + prompt: json_first_text_from_value( + act, + &[ + "backgroundPromptText", + "scenePromptText", + "visualPromptText", + "promptText", + "imagePromptText", + "backgroundPrompt", + "visualPrompt", + ], + ) + .unwrap_or_default(), }) }) .collect() } +fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> { + if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) { + return Err(format!( + "第{}章第{}幕「{}」缺少 backgroundPromptText,不能在幕背景图描述文本生成前直接生图。", + act_ref.chapter_index + 1, + act_ref.act_index + 1, + act_ref.title + )); + } + + Ok(()) +} + +fn json_first_text_from_value(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| json_text_from_value(value, key)) +} + +fn merge_generated_act_backgrounds(target_profile: &mut Value, background_profile: &Value) { + let Some(target_chapters) = target_profile + .get_mut("sceneChapterBlueprints") + .and_then(Value::as_array_mut) + else { + return; + }; + let Some(background_chapters) = background_profile + .get("sceneChapterBlueprints") + .and_then(Value::as_array) + else { + return; + }; + + for (chapter_index, background_chapter) in background_chapters.iter().enumerate() { + let Some(target_acts) = target_chapters + .get_mut(chapter_index) + .and_then(|chapter| chapter.get_mut("acts")) + .and_then(Value::as_array_mut) + else { + continue; + }; + let Some(background_acts) = background_chapter.get("acts").and_then(Value::as_array) else { + continue; + }; + + for (act_index, background_act) in background_acts.iter().enumerate() { + let Some(target_act_object) = target_acts + .get_mut(act_index) + .and_then(Value::as_object_mut) + else { + continue; + }; + let Some(background_act_object) = background_act.as_object() else { + continue; + }; + + // 只合并图片生成产物字段,避免并行分支把其他草稿内容互相覆盖。 + for key in [ + "backgroundImageSrc", + "backgroundAssetId", + "generatedScenePrompt", + "generatedSceneModel", + ] { + if let Some(value) = background_act_object.get(key) { + target_act_object.insert(key.to_string(), value.clone()); + } + } + } + } +} + fn json_text_from_value(value: &Value, key: &str) -> Option { value .get(key) @@ -1497,6 +1760,68 @@ fn json_text_from_value(value: &Value, key: &str) -> Option { .map(ToOwned::to_owned) } +async fn persist_partial_draft_foundation_after_asset_failure( + state: &AppState, + session: &CustomWorldAgentSessionRecord, + owner_user_id: &str, + operation_id: &str, + draft_profile: &Value, + phase_label: &str, + error_message: &str, +) { + let draft_profile_json = match serde_json::to_string(draft_profile) { + Ok(value) => Some(value), + Err(error) => { + tracing::warn!(error = %error, "素材失败后的部分底稿序列化失败"); + None + } + }; + let finalize_result = state + .spacetime_client() + .finalize_custom_world_agent_message(CustomWorldAgentMessageFinalizeRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + operation_id: operation_id.to_string(), + assistant_message_id: None, + assistant_reply_text: None, + phase_label: phase_label.to_string(), + phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"), + operation_status: "failed".to_string(), + operation_progress: 100, + stage: session.stage.clone(), + progress_percent: session.progress_percent, + focus_card_id: session.focus_card_id.clone(), + anchor_content_json: session.anchor_content.to_string(), + creator_intent_json: Some(session.creator_intent.to_string()), + creator_intent_readiness_json: session.creator_intent_readiness.to_string(), + anchor_pack_json: Some(session.anchor_pack.to_string()), + draft_profile_json, + pending_clarifications_json: Value::Array(session.pending_clarifications.clone()).to_string(), + suggested_actions_json: Value::Array(session.suggested_actions.clone()).to_string(), + recommended_replies_json: json!(session.recommended_replies).to_string(), + quality_findings_json: Value::Array(session.quality_findings.clone()).to_string(), + asset_coverage_json: session.asset_coverage.to_string(), + error_message: Some(error_message.to_string()), + updated_at_micros: current_utc_micros(), + }) + .await; + if let Err(error) = finalize_result { + tracing::warn!(error = %error, "素材失败后的部分底稿持久化失败"); + let _ = upsert_custom_world_draft_foundation_progress( + state, + &session.session_id, + owner_user_id, + operation_id, + "failed", + phase_label, + error_message, + 100, + Some(error_message.to_string()), + ) + .await; + } +} + async fn upsert_custom_world_draft_foundation_progress( state: &AppState, session_id: &str, @@ -1755,6 +2080,24 @@ fn has_custom_world_scene_act(profile: Option<&Map>) -> bool { .unwrap_or(false) } +fn ensure_non_empty( + request_context: &RequestContext, + value: &str, + field_name: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(custom_world_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-agent", + "message": format!("{field_name} is required"), + })), + )); + } + + Ok(()) +} + fn map_custom_world_publish_gate_response( gate: CustomWorldPublishGateRecord, ) -> CustomWorldPublishGateResponse { @@ -2090,3 +2433,32 @@ fn current_utc_micros() -> i64 { .expect("system clock should be after unix epoch"); i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_scene_act_refs_accepts_scene_prompt_text_alias() { + let draft_profile = json!({ + "sceneChapterBlueprints": [ + { + "sceneId": "scene-office", + "acts": [ + { + "title": "深夜工位", + "summary": "团队在凌晨三点继续赶版本。", + "scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌" + } + ] + } + ] + }); + + let act_refs = collect_scene_act_refs(&draft_profile); + + assert_eq!(act_refs.len(), 1); + assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌"); + assert!(validate_scene_act_background_prompts(&act_refs).is_ok()); + } +} diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index 9f9ff730..0a97f16a 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -1,5 +1,5 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; -use serde_json::{Map as JsonMap, Value as JsonValue, json}; +use serde_json::{Map as JsonMap, Value as JsonValue}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; 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 d6df6243..314c6ae0 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 @@ -1,4 +1,4 @@ -use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; +use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; @@ -738,7 +738,7 @@ fn build_custom_world_landmark_seed_batch_prompt( ) -> String { [ "请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(), - "后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架与默认生图描述。".to_string(), + "后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架、地点默认生图描述和逐幕背景描述。".to_string(), "你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(), "世界核心信息:".to_string(), build_framework_summary_text(framework, 0), @@ -751,6 +751,7 @@ fn build_custom_world_landmark_seed_batch_prompt( " \"name\": \"场景名称\",".to_string(), " \"description\": \"场景极简描述\",".to_string(), " \"visualDescription\": \"默认场景生图描述\",".to_string(), + " \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(), " \"dangerLevel\": \"low|medium|high|extreme\"".to_string(), " }".to_string(), " ]".to_string(), @@ -760,8 +761,10 @@ fn build_custom_world_landmark_seed_batch_prompt( format!("- 必须生成恰好 {batch_count} 个关键场景。"), "- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(), "- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(), - "- 每个地点只保留:name、description、visualDescription、dangerLevel。".to_string(), + "- 每个地点只保留:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(), "- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(), + "- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(), + "- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(), "- description 控制在 12 到 24 个汉字内。".to_string(), "- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(), "- 所有生成文本都必须使用中文。".to_string(), @@ -780,8 +783,8 @@ fn build_custom_world_landmark_seed_batch_json_repair_prompt( "顶层必须只包含一个 landmarks 数组。".to_string(), format!("必须保留恰好 {expected_count} 个地点对象。"), if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) }, - "每个地点只包含:name、description、visualDescription、dangerLevel。".to_string(), - "如果缺少字段:字符串补空字符串,dangerLevel 补 medium。".to_string(), + "每个地点只包含:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(), + "如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 补空数组,dangerLevel 补 medium。".to_string(), "不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(), "原始文本:".to_string(), response_text.trim().to_string(), @@ -945,6 +948,7 @@ fn build_custom_world_role_batch_json_repair_prompt( response_text.trim().to_string(), ].join("\n") } +#[cfg(test)] fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String { let anchor_content = to_pretty_json(&session.anchor_content); let creator_intent = to_pretty_json(&session.creator_intent); @@ -1063,11 +1067,80 @@ fn build_foundation_draft_profile_from_framework( JsonValue::Array(playable_detailed), ); object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed)); + let scene_chapter_blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks); object.insert("landmarks".to_string(), JsonValue::Array(landmarks)); object.insert("chapters".to_string(), JsonValue::Array(Vec::new())); + object.insert( + "sceneChapterBlueprints".to_string(), + JsonValue::Array(scene_chapter_blueprints), + ); normalize_foundation_draft_profile(JsonValue::Object(object), session) } +fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec { + // 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。 + landmarks + .iter() + .enumerate() + .map(|(chapter_index, landmark)| { + let scene_name = json_text(landmark, "name") + .unwrap_or_else(|| format!("关键场景{}", chapter_index + 1)); + let scene_id = json_text(landmark, "id") + .unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1)); + let summary = json_text(landmark, "description").unwrap_or_default(); + let act_prompts = + json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default(); + let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default(); + + json!({ + "id": scene_id.clone(), + "sceneId": scene_id.clone(), + "title": scene_name, + "summary": summary, + "linkedLandmarkIds": [scene_id.clone()], + "acts": (0..3) + .map(|act_index| build_scene_act_blueprint_from_landmark( + &scene_id, + &summary, + &act_prompts, + &scene_npc_names, + act_index, + )) + .collect::>(), + }) + }) + .collect() +} + +fn build_scene_act_blueprint_from_landmark( + scene_id: &str, + scene_summary: &str, + act_prompts: &[String], + scene_npc_names: &[String], + act_index: usize, +) -> JsonValue { + let act_title = if act_index == 0 { + "第1幕".to_string() + } else { + format!("第{}幕", act_index + 1) + }; + let prompt = act_prompts + .get(act_index) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + // 缺失时保留空值,让后续生图前校验暴露底稿质量问题。 + json!({ + "id": format!("{}-act-{}", scene_id, act_index + 1), + "sceneId": scene_id, + "title": act_title, + "summary": scene_summary, + "backgroundPromptText": prompt, + "encounterNpcIds": scene_npc_names, + }) +} + fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) { if !framework.is_object() { *framework = json!({}); @@ -1469,12 +1542,6 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue { 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) @@ -1497,7 +1564,7 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue { .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| format!("{title}:{summary}。{fallback_prompt}")); + .unwrap_or_default(); object.insert( "backgroundPromptText".to_string(), JsonValue::String(background_prompt), @@ -1523,7 +1590,7 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue { "id": format!("scene-act-{}", index + 1), "title": if index == 0 { "开场场景幕".to_string() } else { format!("第{}幕", index + 1) }, "summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", - "backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。", + "backgroundPromptText": "", }) } @@ -1642,10 +1709,12 @@ fn parse_json_response_text(text: &str) -> Result serde_json::from_str::(trimmed) } +#[cfg(test)] fn to_pretty_json(value: &JsonValue) -> String { serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string()) } +#[cfg(test)] fn is_non_null_json(value: &JsonValue) -> bool { !matches!(value, JsonValue::Null) } @@ -1665,6 +1734,54 @@ mod tests { use super::*; + #[test] + fn scene_chapter_blueprints_use_landmark_act_background_prompts() { + let landmarks = vec![json!({ + "name": "雾港码头", + "description": "旧船骨露出黑潮。", + "actBackgroundPromptTexts": [ + "潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。", + "封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。", + "退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。" + ], + "sceneNpcNames": ["灯童丁"] + })]; + + let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks); + let acts = blueprints[0] + .get("acts") + .and_then(JsonValue::as_array) + .expect("acts should exist"); + + assert_eq!(acts.len(), 3); + assert_eq!( + acts[0].get("backgroundPromptText"), + Some(&json!( + "潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。" + )) + ); + assert!( + !acts[0] + .get("backgroundPromptText") + .and_then(JsonValue::as_str) + .unwrap_or_default() + .contains("第1幕背景") + ); + } + + #[test] + fn normalize_scene_act_keeps_missing_background_prompt_empty() { + let act = normalize_scene_act_blueprint( + json!({ + "title": "第1幕", + "summary": "玩家进入雾港码头。" + }), + 0, + ); + + assert_eq!(act.get("backgroundPromptText"), Some(&json!(""))); + } + #[test] fn foundation_prompt_uses_real_seed_text() { let session = build_test_session(); @@ -1740,7 +1857,7 @@ mod tests { r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, ), llm_response( - r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#, + r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#, ), llm_response( r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#, @@ -1795,6 +1912,24 @@ mod tests { assert!(request_text.contains("叙事档案")); assert!(request_text.contains("养成档案")); assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1")); + assert_eq!( + draft_profile + .get("playableNpcs") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("visualDescription")) + .and_then(JsonValue::as_str), + Some("灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。") + ); + assert_eq!( + draft_profile + .get("storyNpcs") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("visualDescription")) + .and_then(JsonValue::as_str), + Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。") + ); assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航"))); assert!( draft_profile diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1535b170..a4059d93 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -1,4 +1,5 @@ mod admin; +mod ai_generation_drafts; mod ai_tasks; mod api_response; mod app; diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 267a0536..421e3f22 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -52,6 +52,7 @@ use spacetime_client::{ use std::convert::Infallible; use crate::{ + ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, @@ -288,6 +289,16 @@ pub async fn stream_puzzle_agent_message( let session_id_for_stream = session_id.clone(); let owner_user_id_for_stream = owner_user_id.clone(); let stream = async_stream::stream! { + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "puzzle", + owner_user_id_for_stream.as_str(), + session_id_for_stream.as_str(), + payload.client_message_id.as_str(), + "拼图模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行"); + } let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); let turn_result = { let run_turn = run_puzzle_agent_turn( @@ -306,6 +317,7 @@ pub async fn stream_puzzle_agent_message( result = &mut run_turn => break result, maybe_text = reply_rx.recv() => { if let Some(text) = maybe_text { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; yield Ok::(puzzle_sse_json_event_or_error( "reply_delta", json!({ "text": text }), @@ -317,6 +329,7 @@ pub async fn stream_puzzle_agent_message( }; while let Some(text) = reply_rx.recv().await { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; yield Ok::(puzzle_sse_json_event_or_error( "reply_delta", json!({ "text": text }), diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index b207320e..f47ca19e 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -484,4 +484,39 @@ impl SpacetimeClient { }) .await } + + pub async fn upsert_custom_world_agent_operation_progress( + &self, + input: CustomWorldAgentOperationProgressRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentOperationProgressInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + operation_type: parse_rpg_agent_operation_type_record(input.operation_type.as_str())?, + operation_status: parse_rpg_agent_operation_status_record( + input.operation_status.as_str(), + )?, + phase_label: input.phase_label, + phase_detail: input.phase_detail, + operation_progress: input.operation_progress, + error_message: input.error_message, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_custom_world_agent_operation_progress_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f5a49feb..eb598bcb 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -16,8 +16,9 @@ pub use mapper::{ BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, - CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord, - CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, + CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, + CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 96cb0c0b..4432521d 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2726,6 +2726,31 @@ pub(crate) fn format_rpg_agent_operation_type( } } +pub(crate) fn parse_rpg_agent_operation_type_record( + value: &str, +) -> Result { + match value.trim() { + "process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage), + "draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation), + "update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard), + "sync_result_profile" => Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile), + "generate_characters" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters), + "generate_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks), + "generate_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets), + "sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets), + "generate_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets), + "sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets), + "expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail), + "publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld), + "revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint), + "delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters), + "delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent operation type: {other}" + ))), + } +} + pub(crate) fn format_rpg_agent_operation_status( value: crate::module_bindings::RpgAgentOperationStatus, ) -> &'static str { @@ -2806,22 +2831,6 @@ impl TryFrom<&str> for BigFishAssetKind { } } -pub(crate) fn map_big_fish_creation_stage( - value: module_big_fish::BigFishCreationStage, -) -> BigFishCreationStage { - match value { - module_big_fish::BigFishCreationStage::CollectingAnchors => { - BigFishCreationStage::CollectingAnchors - } - module_big_fish::BigFishCreationStage::DraftReady => BigFishCreationStage::DraftReady, - module_big_fish::BigFishCreationStage::AssetRefining => BigFishCreationStage::AssetRefining, - module_big_fish::BigFishCreationStage::ReadyToPublish => { - BigFishCreationStage::ReadyToPublish - } - module_big_fish::BigFishCreationStage::Published => BigFishCreationStage::Published, - } -} - pub(crate) fn parse_big_fish_creation_stage( value: &str, ) -> Result { @@ -3466,6 +3475,21 @@ pub struct CustomWorldAgentOperationRecord { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentOperationProgressRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + // SpacetimeDB 模块侧使用枚举存储操作类型,这里保留字符串给 API 层做轻量传参。 + pub operation_type: String, + pub operation_status: String, + pub phase_label: String, + pub phase_detail: String, + pub operation_progress: u32, + pub error_message: Option, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq)] pub struct CustomWorldDraftCardRecord { pub card_id: String, 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 9c5fad25..d1817e04 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -116,6 +116,7 @@ pub mod custom_world_agent_message_submit_input_type; pub mod custom_world_agent_operation_type; pub mod custom_world_agent_operation_get_input_type; pub mod custom_world_agent_operation_procedure_result_type; +pub mod custom_world_agent_operation_progress_input_type; pub mod custom_world_agent_operation_snapshot_type; pub mod custom_world_agent_session_type; pub mod custom_world_agent_session_create_input_type; @@ -475,6 +476,7 @@ pub mod unpublish_custom_world_profile_and_return_procedure; pub mod update_puzzle_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; +pub mod upsert_custom_world_agent_operation_progress_procedure; pub mod upsert_custom_world_profile_and_return_procedure; pub mod upsert_npc_state_and_return_procedure; pub mod upsert_platform_browse_history_and_return_procedure; @@ -586,6 +588,7 @@ pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSub pub use custom_world_agent_operation_type::CustomWorldAgentOperation; pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; +pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput; pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; pub use custom_world_agent_session_type::CustomWorldAgentSession; pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; @@ -942,6 +945,7 @@ pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_wo pub use update_puzzle_work_procedure::update_puzzle_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; +pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress; pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return; pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return; pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return; 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 c367beeb..5754f137 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1572,7 +1572,34 @@ fn delete_custom_world_agent_session_tx( .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()); + let published_profile = ctx + .db + .custom_world_profile() + .iter() + .find(|row| { + row.owner_user_id == input.owner_user_id + && row.source_agent_session_id.as_deref() == Some(input.session_id.as_str()) + && row.deleted_at.is_none() + }) + .ok_or_else(|| "已发布 RPG 作品缺少关联 profile,无法删除".to_string())?; + + // 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”收敛为 + // profile 软删除,避免前端误入草稿删除接口时把业务分支放大成上游 502。 + delete_custom_world_profile_record( + ctx, + CustomWorldProfileDeleteInput { + profile_id: published_profile.profile_id, + owner_user_id: input.owner_user_id.clone(), + deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(), + }, + )?; + + return list_custom_world_work_snapshots( + ctx, + CustomWorldWorksListInput { + owner_user_id: input.owner_user_id, + }, + ); } // 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。 diff --git a/src/components/CharacterAnimator.test.tsx b/src/components/CharacterAnimator.test.tsx index 022330dd..4a3c9a6c 100644 --- a/src/components/CharacterAnimator.test.tsx +++ b/src/components/CharacterAnimator.test.tsx @@ -73,4 +73,19 @@ describe('CharacterAnimator portrait fallbacks', () => { expect(image.style.transform).toContain('rotate(-90deg)'); expect(image.style.transform).toContain('scaleX(-1)'); }); + + it('uses generated portrait for movement when generated animation is missing', () => { + render( + , + ); + + const image = screen.getByRole('img', { + name: /沈砺 run animation/i, + }) as HTMLImageElement; + + expect(image.getAttribute('src')).toBe('/generated/portrait.png'); + }); }); diff --git a/src/components/CharacterAnimator.tsx b/src/components/CharacterAnimator.tsx index 02ee614e..df0843ed 100644 --- a/src/components/CharacterAnimator.tsx +++ b/src/components/CharacterAnimator.tsx @@ -107,6 +107,9 @@ export const CharacterAnimator: React.FC = ({ playbackRate = 1, }) => { const explicitConfig = character.animationMap?.[state]; + const hasGeneratedPortraitOnly = + Boolean(character.generatedVisualAssetId && character.portrait?.trim()) + && !explicitConfig; const usePortraitIdleFallback = !explicitConfig && state === AnimationState.IDLE; const usePortraitDeathFallback = @@ -118,7 +121,7 @@ export const CharacterAnimator: React.FC = ({ character.animationMap?.[AnimationState.IDLE] ?? DEFAULT_ANIMATIONS[AnimationState.IDLE]; const fallbackToPortrait = - usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError; + hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError; const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig; const startFrame = typeof config.startFrame === 'number' && Number.isFinite(config.startFrame) diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 63cfe25d..a298087e 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -341,6 +341,46 @@ function resolveSceneCardImage(params: { return firstActImageSrc || params.sceneImageSrc?.trim() || ''; } +function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) { + return sceneChapters.flatMap((chapter) => + chapter.acts + .map((act, index) => ({ + id: act.id.trim() || `${chapter.id}-act-${index}`, + title: act.title.trim() || `第${index + 1}幕`, + imageSrc: act.backgroundImageSrc?.trim() || '', + })) + .filter((act) => act.imageSrc), + ); +} + +function SceneActPreviewStrip({ + acts, + sceneName, +}: { + acts: Array<{ id: string; title: string; imageSrc: string }>; + sceneName: string; +}) { + if (acts.length <= 0) return null; + + return ( +
+ {acts.map((act) => ( +
+ +
+ ))} +
+ ); +} + function CatalogCard({ title, description, @@ -1015,6 +1055,7 @@ export function CustomWorldEntityCatalog({ sceneChapters: openingSceneChapters, }), sceneChapters: openingSceneChapters, + actPreviews: collectSceneActImagePreviews(openingSceneChapters), searchText: [ buildOpeningSceneSearchText(profile, resolvedCampScene), buildSceneChapterSearchText(openingSceneChapters, roleById), @@ -1039,6 +1080,7 @@ export function CustomWorldEntityCatalog({ sceneChapters, }), sceneChapters, + actPreviews: collectSceneActImagePreviews(sceneChapters), searchText: [ buildLandmarkSearchText(landmark, storyNpcById, landmarkById), buildSceneChapterSearchText(sceneChapters, roleById), @@ -1576,6 +1618,12 @@ export function CustomWorldEntityCatalog({ tone="landscape" /> } + actions={ + + } disabled={scene.kind === 'camp' && isBulkDeleteMode} /> )) diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index de46159d..2a6d75ca 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -262,6 +262,21 @@ const baseProfile = { actGoal: '接住首幕压力', transitionHook: '继续逼近钟楼深处。', }, + { + id: 'scene-act-2', + sceneId: 'landmark-1', + title: '钟楼回响', + summary: '第二幕把旧钟与暗线证据推到台前。', + stageCoverage: ['investigation'], + backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png', + backgroundAssetId: 'scene-asset-2', + encounterNpcIds: ['story-1'], + primaryNpcId: 'story-1', + linkedThreadIds: [], + advanceRule: 'after_clue_found', + actGoal: '找到旧钟证据', + transitionHook: '钟楼深处传来第二次回响。', + }, ], }, ], @@ -400,7 +415,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder', expect(screen.getByText('已生成主图')).toBeTruthy(); }); -test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => { +test('landmark tab previews every generated act image while keeping chapter details out of list', async () => { const user = userEvent.setup(); render(); @@ -414,6 +429,17 @@ test('landmark tab uses first act image as scene card preview and keeps chapter expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe( '/generated-custom-world-scenes/scene-act-1.png', ); + + expect( + (screen.getByRole('img', { + name: '沉钟栈桥-潮声逼近', + }) as HTMLImageElement).getAttribute('src'), + ).toBe('/generated-custom-world-scenes/scene-act-1.png'); + expect( + (screen.getByRole('img', { + name: '沉钟栈桥-钟楼回响', + }) as HTMLImageElement).getAttribute('src'), + ).toBe('/generated-custom-world-scenes/scene-act-2.png'); }); test('readOnly result view hides edit and create actions for agent preview mode', async () => { @@ -535,10 +561,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi await user.click(screen.getByRole('button', { name: '发布并进入世界' })); - expect( - screen.getByRole('dialog', { name: '发布前检查' }), - ).toBeTruthy(); - expect(screen.getByText(/当前还有 2 个阻断项/u)).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy(); + expect(screen.getByText('发布检查')).toBeTruthy(); + expect(screen.getByText('封面设置')).toBeTruthy(); expect( screen.getByText(/仍有角色缺少正式主图或动作资产/u), ).toBeTruthy(); diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index d20b88c6..cb879e3b 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -10,11 +10,14 @@ import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ + authEntry: vi.fn(), + changePassword: vi.fn(), ensureStoredAccessToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), + resetPassword: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), consumeAuthCallbackResult: vi.fn(), @@ -26,10 +29,13 @@ vi.mock('../../services/apiClient', () => ({ })); vi.mock('../../services/authService', () => ({ + authEntry: authMocks.authEntry, bindWechatPhone: vi.fn(), + changePassword: authMocks.changePassword, changePhoneNumber: vi.fn(), consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, ensureAutoAuthUser: authMocks.ensureAutoAuthUser, + getStoredLastLoginPhone: vi.fn(() => ''), getAuthAuditLogs: vi.fn(), getAuthLoginOptions: authMocks.getAuthLoginOptions, getAuthRiskBlocks: vi.fn(), @@ -40,8 +46,10 @@ vi.mock('../../services/authService', () => ({ loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), + resetPassword: authMocks.resetPassword, revokeAuthSession: vi.fn(), sendPhoneLoginCode: authMocks.sendPhoneLoginCode, + setStoredLastLoginPhone: vi.fn(), startWechatLogin: authMocks.startWechatLogin, })); @@ -86,6 +94,9 @@ beforeEach(() => { availableLoginMethods: ['phone'], }); authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); + authMocks.authEntry.mockResolvedValue(mockUser); + authMocks.changePassword.mockResolvedValue(mockUser); + authMocks.resetPassword.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, expiresInSeconds: 300, @@ -203,13 +214,13 @@ test('auth gate opens a login modal for protected actions and resumes after logi await user.click(await screen.findByRole('button', { name: '进入作品' })); - const dialog = screen.getByRole('dialog', { name: '登录账号' }); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); expect(dialog).toBeTruthy(); expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.type(within(dialog).getByLabelText('验证码'), '123456'); - await user.click(within(dialog).getByRole('button', { name: '登录' })); + await user.click(within(dialog).getByRole('button', { name: '注册/登录' })); await waitFor(() => { expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( @@ -220,7 +231,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(onAuthenticated).toHaveBeenCalledTimes(1); }); - expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); test('auth state refresh keeps mounted platform content and local tab state', async () => { @@ -280,7 +291,7 @@ test('auth gate shows sms send feedback in the login modal', async () => { await user.click(await screen.findByRole('button', { name: '进入作品' })); - const dialog = screen.getByRole('dialog', { name: '登录账号' }); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); await user.click(within(dialog).getByRole('button', { name: '获取验证码' })); @@ -296,7 +307,48 @@ test('auth gate shows sms send feedback in the login modal', async () => { }); expect( - within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'), + within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'), ).toBeTruthy(); expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); }); + +test('auth gate separates sms and password login by tabs', async () => { + const user = userEvent.setup(); + + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone', 'password'], + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + + const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect( + within(dialog) + .getByRole('tab', { name: '短信登录' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(within(dialog).queryByLabelText('密码')).toBeNull(); + + await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); + + expect( + within(dialog) + .getByRole('tab', { name: '密码登录' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect(within(dialog).queryByLabelText('验证码')).toBeNull(); + + await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000'); + await user.type(within(dialog).getByLabelText('密码'), 'passw0rd'); + await user.click(within(dialog).getByRole('button', { name: '注册/登录' })); + + await waitFor(() => { + expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd'); + }); +}); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index ea02a7e3..02aa9bb0 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -10,6 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type SmsScene = 'login' | 'reset_password'; +type LoginTab = 'phone' | 'password'; type LoginScreenProps = { isOpen: boolean; @@ -72,6 +73,18 @@ export function LoginScreen({ const passwordLoginEnabled = availableLoginMethods.includes('password'); const phoneLoginEnabled = availableLoginMethods.includes('phone'); const wechatLoginEnabled = availableLoginMethods.includes('wechat'); + const [activeLoginTab, setActiveLoginTab] = useState('phone'); + + useEffect(() => { + if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) { + setActiveLoginTab('password'); + return; + } + + if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) { + setActiveLoginTab('phone'); + } + }, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]); useEffect(() => { if (cooldownSeconds <= 0) { @@ -152,8 +165,29 @@ export function LoginScreen({ onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)} /> ) : ( -
- {passwordLoginEnabled ? ( +
+ {phoneLoginEnabled && passwordLoginEnabled ? ( +
+ setActiveLoginTab('phone')} + > + 短信登录 + + setActiveLoginTab('password')} + > + 密码登录 + +
+ ) : null} + + {passwordLoginEnabled && activeLoginTab === 'password' ? (
{ @@ -162,14 +196,13 @@ export function LoginScreen({ }} >