diff --git a/.idea/.name b/.idea/.name index f03513b6..cf8f80f7 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -PreGameSelectionFlow.tsx \ No newline at end of file +mod.rs \ No newline at end of file diff --git a/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md b/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md index 7e3c0693..5eb299f5 100644 --- a/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_AGENT_ENTITY_ACTIONS_RS_MIGRATION_2026-04-24.md @@ -83,3 +83,31 @@ cargo check -p spacetime-module ``` 结果:通过。`spacetime-module` 仅保留仓库既有 glob re-export warning。 + +## 2026-04-24 追加:结果页删除与资产动作闭环 + +本次继续补齐除长尾补全外的结果页可见动作: + +1. `delete_characters` / `delete_landmarks` 已由 Rust SpacetimeDB reducer 直接更新 `draft_profile_json`、`result_preview_json`、`publish_gate_json`、`checkpoints_json`、`custom_world_draft_card`、`custom_world_agent_operation` 与 `custom_world_agent_message`。 +2. `generate_characters` 增加 `roleType`,可扮演角色写入 `playableNpcs`,场景角色写入 `storyNpcs`,不再把可扮演角色落到场景角色列表。 +3. `generate_role_assets` / `generate_scene_assets` 不再走占位动作,Rust 会校验目标对象、切到 `visual_refining`、设置 `focus_card_id`,并记录 operation/message。 +4. `sync_role_assets` / `sync_scene_assets` 已迁移 Node 的 profile 字段写回逻辑:角色写回 `imageSrc`、`generatedVisualAssetId`、`generatedAnimationSetId`、`animationMap`;场景写回 `imageSrc`、`generatedSceneAssetId`、`generatedScenePrompt`、`generatedSceneModel`,并同步 `sceneChapters.acts` 背景字段。 +5. 前端结果页角色卡展示“生成资产”,场景卡展示“生成场景图”,均通过 `autosaveCoordinator.executeAgentActionAndWait` 调 Rust API 并用最新 session 重建预览。 + +本轮仍不迁移 `expand_long_tail`,保持后续单独设计。 + +## 2026-04-24 追加:创作 Tab 删除作品入口 + +用户在 `http://127.0.0.1:3000/` 的“创作”Tab 看不到删除作品入口,原因是 `RpgEntryHomeView` 的 `CreationLibraryCard` 只支持整卡打开详情,没有接收删除回调。已补齐: + +1. `CreationLibraryCard` 右上角展示“删除”按钮,点击时阻止整卡打开详情。 +2. `RpgEntryHomeView` 新增 `onDeleteLibraryEntry` 与 `deletingLibraryEntryId` props。 +3. `PlatformEntryFlowShellImpl` 复用 `deleteRpgEntryWorldProfile`,删除后刷新我的作品列表与公开广场。 + +链路保持为:前端创作 Tab -> `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除 profile / 移除 gallery 读模型。 + +## 2026-04-24 追加:创作 Hub 草稿删除入口修正 + +截图中的“创作”Tab 实际渲染的是 `CustomWorldCreationHub` / `CustomWorldWorkCard`,不是默认 `RpgEntryHomeView` 的 `CreationLibraryCard`。此前 Hub 只给 `status=published` 的 RPG 作品传入删除回调,导致草稿卡片没有“删除”按钮。 + +修正后:只要 RPG 创作条目存在 `profileId`,无论 `draft` 还是 `published`,都会在卡片底部动作区展示“删除”。删除继续复用 `PlatformEntryFlowShellImpl.handleDeletePublishedWork`,走 `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除。 diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md index 192f7d2d..18c58825 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md @@ -52,3 +52,34 @@ cargo test -p api-server custom_world_foundation_draft --no-default-features ``` 结果:`3 passed`。 + +## 2026-04-24 进度链路补齐 + +本次继续补齐“点击生成世界草稿”后的异步执行方式,避免 HTTP 请求阻塞到全部 LLM 调用结束才返回: + +1. `execute_custom_world_agent_action(draft_foundation)` 现在先创建 `draft_foundation` running operation,并立即把 `operationId` 返回给前端。 +2. API 后台任务继续执行 Node 同序多阶段生成;前端已有的 operation polling 可以持续读取阶段进度。 +3. Rust 生成器按 Node 的 `onProgress` 节点写入: + - `12`:整理世界骨架。 + - `16-30`:生成可扮演角色。 + - `30-44`:生成场景角色。 + - `44-56`:生成关键场景。 + - `56-66`:建立场景连接。 + - `66-76`:补全可扮演角色叙事基础。 + - `76-84`:补全可扮演角色档案细节。 + - `84-92`:补全场景角色叙事基础。 + - `92-96`:补全场景角色档案细节。 + - `97`:编译世界底稿。 + - `98`:编译草稿卡。 +4. SpacetimeDB 新增 `upsert_custom_world_agent_operation_progress`,只更新/创建 operation 进度,不插入聊天消息、不推进 turn,专门承接生成中的阶段进度。 +5. 最终落库仍复用 `execute_custom_world_agent_action(draft_foundation)`,但允许复用同一个 running operation 完成写入,避免中间断点和重复 operation。 + +补充验证: + +```bash +cargo check -p api-server +cargo check -p spacetime-module +cargo test -p api-server custom_world_foundation_draft -- --nocapture +``` + +结果:后端检查通过;`custom_world_foundation_draft` 相关测试 `3 passed`。 diff --git a/docs/technical/RPG_CREATION_RESULT_EDITING_MIGRATION_AUDIT_2026-04-24.md b/docs/technical/RPG_CREATION_RESULT_EDITING_MIGRATION_AUDIT_2026-04-24.md index d479f263..e2a03002 100644 --- a/docs/technical/RPG_CREATION_RESULT_EDITING_MIGRATION_AUDIT_2026-04-24.md +++ b/docs/technical/RPG_CREATION_RESULT_EDITING_MIGRATION_AUDIT_2026-04-24.md @@ -26,26 +26,28 @@ Agent 结果页点击新增场景角色 / 新增场景 -> 刷新结果页 profile ``` -说明:当前可扮演角色 tab 的“新增可扮演角色”也会调用 `generate_characters`,后端现阶段会追加到 `storyNpcs`。因此严格意义上的“新增可扮演角色”仍未完整迁移,需要后续给 action 增加角色类型参数或新增 `generate_playable_characters`。 +说明:当前可扮演角色 tab 的“新增可扮演角色”会调用 `generate_characters` 并传入 `roleType=playable`,Rust 会写入 `draftProfile.playableNpcs`;场景角色则写入 `draftProfile.storyNpcs`。 ## 已迁移 / 可见 1. 删除作品:已有 Rust + SpacetimeDB 软删除链路。 -2. 新增场景角色:结果页可见,调用 Rust `generate_characters`。 -3. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks`。 -4. Agent 结果页发布进入世界:已有 `publish_world` + publish gate 链路。 -5. 手动编辑结果页 profile:目前仍通过 `sync_result_profile` 自动保存回 Agent session。 +2. 新增可扮演角色:结果页可见,调用 Rust `generate_characters(roleType=playable)` 并写入 `playableNpcs`。 +3. 新增场景角色:结果页可见,调用 Rust `generate_characters(roleType=story)` 并写入 `storyNpcs`。 +4. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks` 并写入 `landmarks`。 +5. 批量删除场景角色:结果页可见,调用 Rust `delete_characters`,同步删除 profile 与 draft card。 +6. 批量删除场景:结果页可见,调用 Rust `delete_landmarks`,同步删除 profile、连接与 draft card。 +7. 角色资产准备:结果页角色卡可见“生成资产”,调用 Rust `generate_role_assets`,进入 `visual_refining` 并聚焦角色。 +8. 场景资产准备:结果页场景卡可见“生成场景图”,调用 Rust `generate_scene_assets`,进入 `visual_refining` 并聚焦场景。 +9. 角色资产同步:Rust `sync_role_assets` 会把 `portraitPath / generatedVisualAssetId / generatedAnimationSetId / animationMap` 写入 profile、draft card、asset coverage、preview、checkpoint、operation 与 message。 +10. 场景资产同步:Rust `sync_scene_assets` 会把 `imageSrc / generatedSceneAssetId / prompt / model` 写入 camp 或 landmark,并同步 `sceneChapters.acts`、draft card、asset coverage、preview、checkpoint、operation 与 message。 +11. Agent 结果页发布进入世界:已有 `publish_world` + publish gate 链路。 +12. 手动编辑结果页 profile:目前仍通过 `sync_result_profile` 自动保存回 Agent session。 ## 尚未完整迁移的结果页编辑功能 -1. 新增可扮演角色:前端有入口,但 Rust action 暂无角色类型区分,当前会落到 `storyNpcs`。 -2. 批量删除场景角色:前端只改本地 profile,再靠 `sync_result_profile` 同步,不是独立 Rust action。 -3. 批量删除场景:前端只改本地 profile,再靠 `sync_result_profile` 同步,不是独立 Rust action。 -4. 单个角色 / 场景的细粒度编辑:前端 modal 仍编辑本地 profile,再靠 `sync_result_profile` 同步;SpacetimeDB 虽有 `update_draft_card`,但结果页表单尚未按 card section action 化。 -5. 角色资产生成:`generate_role_assets / sync_role_assets` Rust 侧仍是 placeholder 或外部链路未完全接入结果页。 -6. 场景资产生成:`generate_scene_assets / sync_scene_assets` Rust 侧仍是 placeholder 或外部链路未完全接入结果页。 -7. 长尾补全:`expand_long_tail` Rust 侧仍是 placeholder。 -8. 回滚 checkpoint:Rust 有 `revert_checkpoint`,但结果页没有清晰可见入口。 +1. 单个角色 / 场景的细粒度编辑:前端 modal 仍编辑本地 profile,再靠 `sync_result_profile` 同步;SpacetimeDB 虽有 `update_draft_card`,但结果页表单尚未按 card section action 化。 +2. 长尾补全:`expand_long_tail` 本轮明确排除,Rust 侧仍是 placeholder。 +3. 回滚 checkpoint:Rust 有 `revert_checkpoint`,但结果页没有清晰可见入口。 ## 下一步建议 diff --git a/docs/technical/RPG_PROMPT_SCRIPT_EXTRACTION_PLAN_2026-04-24.md b/docs/technical/RPG_PROMPT_SCRIPT_EXTRACTION_PLAN_2026-04-24.md new file mode 100644 index 00000000..4baa80a1 --- /dev/null +++ b/docs/technical/RPG_PROMPT_SCRIPT_EXTRACTION_PLAN_2026-04-24.md @@ -0,0 +1,33 @@ +# RPG 创作与资产提示词脚本抽离方案(2026-04-24) + +## 背景 +`server-rs/crates/api-server` 中 RPG 创作链路已经承接草稿生成、结果页补角色/补场景、场景图、角色图与角色动作生成。此前提示词散落在路由处理文件中,导致玩法规则、资产规则与结果页生成规则混杂,后续迭代容易出现落地漂移。 + +## 落地边界 +本次只调整 Rust 后端 `api-server` 内的提示词组织,不兼容 `server-node`,也不改动前端展示文案。 + +## 模块拆分 +1. `custom_world_rpg_draft_prompts.rs` + - 承载 RPG 玩法草稿生成相关提示词。 + - 覆盖八锚点共创主提示词、状态识别提示词、模式规则、用户输入信号规则、上下文渲染。 + - `custom_world_agent_turn.rs` 只保留流程编排、LLM 调用和结果规范化。 + +2. `custom_world_asset_prompts.rs` + - 承载生图、生动作相关提示词。 + - 覆盖角色主图提示词、角色主图负面提示词、角色动作视频/序列帧提示词、动作兜底安全提示词。 + - 场景图和封面图提示词也属于自定义世界资产提示词,统一迁入该模块。 + +3. `custom_world_result_prompts.rs` + - 承载结果页新增实体相关提示词。 + - 覆盖新增可扮演角色、新增场景角色、新增场景的 LLM system/user prompt 构造。 + - 路由层继续负责 fallback 与返回结构,提示词模块只负责生成可审计的 prompt 文本。 + +## 约束 +- 提示词模块只做纯函数拼装,不访问网络、文件、数据库或 SpacetimeDB。 +- 保留原中文提示词语义,不把中文改写成英文。 +- 原有 fallback 行为不变:LLM 不可用或解析失败时仍回退本地生成。 +- 仅做局部迁移,避免整文件重写导致中文编码风险。 + +## 验证 +- `cargo test -p api-server` 应能通过或至少完成编译阶段。 +- 既有单元测试中关于角色图、动作结果 payload、场景图请求的断言应保持不变。 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 e02caca2..3e6d3856 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -43,7 +43,12 @@ use shared_contracts::assets::{ use spacetime_client::SpacetimeClientError; use crate::{ - api_response::json_success_body, http_error::AppError, request_context::RequestContext, + api_response::json_success_body, + custom_world_asset_prompts::{ + build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt, + }, + http_error::AppError, + request_context::RequestContext, state::AppState, }; use tokio::time::sleep; @@ -1713,310 +1718,11 @@ fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAsset } } -fn build_character_animation_prompt( - strategy: &CharacterAnimationStrategy, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - animation: &str, - frame_count: u32, - fps: u32, - duration_seconds: u32, - loop_: bool, - use_chroma_key: bool, -) -> String { - match strategy { - CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt( - animation, - prompt_text, - character_brief_text, - action_template_id, - loop_, - use_chroma_key, - ), - CharacterAnimationStrategy::ImageSequence => { - build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key) - } - CharacterAnimationStrategy::MotionTransfer - | CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt( - animation, - prompt_text, - character_brief_text, - action_template_id, - loop_, - use_chroma_key, - fps, - duration_seconds, - ), - } -} - -fn build_image_sequence_prompt( - animation: &str, - prompt_text: &str, - frame_count: u32, - use_chroma_key: bool, -) -> String { - [ - format!( - "同一角色连续 {} 帧动作序列,动作主题是 {}。", - frame_count, animation - ), - "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), - "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), - if use_chroma_key { - "纯绿色背景,无地面装饰,方便后期抠像。".to_string() - } else { - "背景尽量纯净,避免复杂场景。".to_string() - }, - prompt_text.trim().to_string(), - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -fn build_npc_animation_prompt( - animation: &str, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - loop_: bool, - use_chroma_key: bool, - fps: u32, - duration_seconds: u32, -) -> String { - let character_brief = build_compact_animation_character_brief(character_brief_text); - let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); - let loop_rule = if loop_ { - "这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。" - .to_string() - } else if animation == "die" { - "这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。" - .to_string() - } else { - "这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string() - }; - - if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) { - return [ - format!( - "单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。", - template.animation - ), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - format!("动作补充:{}。", template.prompt_suffix), - if action_detail_text.is_empty() { - String::new() - } else { - format!("动作细节:{}。", action_detail_text) - }, - format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), - loop_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" "); - } - - [ - format!("单人 NPC 全身动作视频,动作主题是 {}。", animation), - "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), - "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - if action_detail_text.is_empty() { - String::new() - } else { - action_detail_text - }, - format!( - "目标帧率 {} fps,时长约 {} 秒。", - fps.clamp(1, 60), - duration_seconds.clamp(1, 8) - ), - loop_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -fn build_ark_character_animation_prompt( - animation: &str, - prompt_text: &str, - character_brief_text: Option<&str>, - action_template_id: Option<&str>, - loop_: bool, - use_chroma_key: bool, -) -> String { - let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_"); - let normalized_animation_name = if normalized_animation_name.is_empty() { - "idle".to_string() - } else { - normalized_animation_name - }; - let character_brief = build_compact_animation_character_brief(character_brief_text); - let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); - let frame_rule = if loop_ { - "首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。".to_string() - } else { - "首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。".to_string() - }; - - if let Some(template) = action_template_id.and_then(find_motion_template) { - return [ - format!( - "单人 NPC 全身动作视频,动作英文名是 {}。角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。", - normalized_animation_name - ), - "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - format!("动作补充:{}。", template.prompt_suffix), - if action_detail_text.is_empty() { - String::new() - } else { - format!("动作细节:{}。", action_detail_text) - }, - frame_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" "); - } - - [ - format!( - "单人 NPC 全身动作视频,动作英文名是 {}。", - normalized_animation_name - ), - "角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。" - .to_string(), - "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() - } else { - "背景简洁纯净,无复杂场景。".to_string() - }, - if character_brief.is_empty() { - String::new() - } else { - format!("角色设定:{}。", character_brief) - }, - if action_detail_text.is_empty() { - String::new() - } else { - format!("动作细节:{}。", action_detail_text) - }, - frame_rule, - ] - .into_iter() - .filter(|value| !value.trim().is_empty()) - .collect::>() - .join(" ") -} - -fn build_fallback_moderation_safe_animation_prompt( - animation: &str, - loop_: bool, - use_chroma_key: bool, -) -> String { - [ - format!("单人全身角色动作视频,动作主题是 {}。", animation), - "角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(), - if loop_ { - "循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string() - } else { - "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() - }, - if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() - } else { - "背景简洁纯净。".to_string() - }, - ] - .join(" ") -} - -fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String { - value - .replace(char::is_whitespace, " ") - .replace("血浆", "") - .replace("喷血", "") - .replace("鲜血", "") - .replace("断肢", "") - .replace("斩首", "") - .replace("裸体", "") - .replace("裸露", "") - .replace("色情", "") - .replace("性交", "") - .replace("死亡", "倒地结束") - .replace("死去", "倒地结束") - .replace("击杀", "倒地结束") - .replace("受击", "失衡") - .replace("受伤", "失衡") - .replace("砍杀", "挥击") - .replace("斩击", "挥击") - .split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(max_length) - .collect::() - .trim() - .to_string() -} - -fn build_compact_animation_character_brief(value: Option<&str>) -> String { - let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160); - if normalized.is_empty() { - return String::new(); - } - normalized - .split(['/', '|', '\n', ',', ',', '。', ';', ';']) - .map(str::trim) - .filter(|item| !item.is_empty()) - .take(4) - .collect::>() - .join(",") -} - -fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> { +pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> { BUILT_IN_MOTION_TEMPLATES .iter() .find(|template| template.id == id.trim()) } - fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String { let candidate = match payload.strategy { CharacterAnimationStrategy::ImageSequence => payload.image_sequence_model.as_str(), @@ -3486,12 +3192,12 @@ fn character_animation_error_response( error.into_response_with_context(Some(request_context)) } -struct MotionTemplate { - id: &'static str, - label: &'static str, - animation: &'static str, - prompt_suffix: &'static str, - notes: &'static str, +pub(crate) struct MotionTemplate { + pub(crate) id: &'static str, + pub(crate) label: &'static str, + pub(crate) animation: &'static str, + pub(crate) prompt_suffix: &'static str, + pub(crate) notes: &'static str, } impl MotionTemplate { @@ -3677,6 +3383,11 @@ mod tests { } #[test] + pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> { + BUILT_IN_MOTION_TEMPLATES + .iter() + .find(|template| template.id == id.trim()) + } fn resolve_character_animation_model_uses_strategy_specific_field() { let payload = CharacterAnimationGenerateRequest { character_id: "hero".to_string(), 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 9aba2d50..9c91e0fe 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -32,7 +32,12 @@ use shared_contracts::assets::{ use spacetime_client::SpacetimeClientError; use crate::{ - api_response::json_success_body, http_error::AppError, request_context::RequestContext, + api_response::json_success_body, + custom_world_asset_prompts::{ + build_character_visual_negative_prompt, build_character_visual_prompt, + }, + http_error::AppError, + request_context::RequestContext, state::AppState, }; use tokio::time::sleep; @@ -671,58 +676,8 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob } } -fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String { - let merged = [character_brief_text.unwrap_or_default(), prompt_text] - .into_iter() - .map(str::trim) - .filter(|value| !value.is_empty()) - .collect::>() - .join("\n"); - - format!( - "{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。", - if merged.is_empty() { - "自定义世界角色,服装完整,姿态自然。" - } else { - merged.as_str() - } - ) -} - -fn build_character_visual_negative_prompt() -> String { - [ - "正面视角", - "左朝向", - "完全 90 度纯右视图", - "镜头透视", - "半身像", - "脚被裁切", - "头顶被裁切", - "多角色", - "复杂背景", - "建筑场景", - "漂浮物", - "烟雾环境", - "武器消失", - "武器换手", - "额外手臂", - "额外腿", - "服装变化", - "脸部变化", - "模糊", - "运动模糊", - "文字", - "水印", - "UI 元素", - "软萌 Q版大头贴", - "儿童绘本风", - "厚涂插画感", - "低对比柔边", - ] - .join(",") -} - fn require_dashscope_settings(state: &AppState) -> Result { + // Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。 let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); if base_url.is_empty() { return Err( @@ -752,7 +707,6 @@ fn require_dashscope_settings(state: &AppState) -> Result Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index ae50ce3e..14240199 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -29,10 +29,10 @@ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, - CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, - CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, - CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, - CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, + CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, @@ -925,39 +925,44 @@ pub async fn execute_custom_world_agent_action( })), )); } - let draft_result = generate_custom_world_foundation_draft(llm_client, &session) + let operation_id = build_prefixed_uuid_id("operation-"); + let operation = state + .spacetime_client() + .upsert_custom_world_agent_operation_progress( + CustomWorldAgentOperationProgressRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + operation_id: operation_id.clone(), + operation_type: "draft_foundation".to_string(), + operation_status: "running".to_string(), + phase_label: "整理世界骨架".to_string(), + phase_detail: "正在校验已确认锚点,并准备第一版世界框架生成链路。" + .to_string(), + operation_progress: 12, + error_message: None, + updated_at_micros: submitted_at_micros, + }, + ) .await - .map_err(|message| { + .map_err(|error| { custom_world_error_response( &request_context, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "custom-world-agent", - "message": message, - })), + map_custom_world_client_error(error), ) })?; - build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json) - .map_err(|error| { - let (status, message) = match error { - DraftFoundationPayloadError::SerializePayload(message) => { - (StatusCode::BAD_REQUEST, message) - } - DraftFoundationPayloadError::InvalidPayloadShape => ( - StatusCode::BAD_REQUEST, - "action payload 必须是 object".to_string(), - ), - DraftFoundationPayloadError::InvalidGeneratedDraft(message) => { - (StatusCode::BAD_GATEWAY, message) - } - }; - custom_world_error_response( - &request_context, - AppError::from_status(status).with_details(json!({ - "provider": "custom-world-agent", - "message": message, - })), - ) - })? + spawn_custom_world_draft_foundation_job( + state.clone(), + session, + owner_user_id, + operation_id, + payload, + ); + return Ok(json_success_body( + Some(&request_context), + json!({ + "operation": map_custom_world_agent_operation_response(operation), + }), + )); } else { let generation_result = generate_custom_world_agent_entities(llm_client, &session, &payload) @@ -1021,6 +1026,177 @@ pub async fn execute_custom_world_agent_action( )) } +fn spawn_custom_world_draft_foundation_job( + state: AppState, + session: CustomWorldAgentSessionRecord, + owner_user_id: String, + operation_id: String, + payload: ExecuteCustomWorldAgentActionRequest, +) { + tokio::spawn(async move { + let Some(llm_client) = state.llm_client().cloned() else { + let _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "failed", + "底稿生成失败", + "服务端尚未配置可用的 LLM API Key", + 100, + Some("服务端尚未配置可用的 LLM API Key".to_string()), + ) + .await; + return; + }; + + let progress_state = state.clone(); + let progress_session_id = session.session_id.clone(); + let progress_owner_user_id = owner_user_id.clone(); + let progress_operation_id = operation_id.clone(); + let draft_result = + generate_custom_world_foundation_draft(&llm_client, &session, move |progress| { + let progress_state = progress_state.clone(); + let session_id = progress_session_id.clone(); + let owner_user_id = progress_owner_user_id.clone(); + let operation_id = progress_operation_id.clone(); + tokio::spawn(async move { + let _ = upsert_custom_world_draft_foundation_progress( + &progress_state, + &session_id, + &owner_user_id, + &operation_id, + "running", + progress.phase_label.as_str(), + progress.phase_detail.as_str(), + progress.progress, + None, + ) + .await; + }); + }) + .await; + + let draft_result = match draft_result { + Ok(result) => result, + Err(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 _ = upsert_custom_world_draft_foundation_progress( + &state, + &session.session_id, + &owner_user_id, + &operation_id, + "running", + "编译草稿卡", + "正在把世界底稿整理成可浏览的卡片摘要和详情结构。", + 98, + None, + ) + .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; + } + }; + + if let Err(error) = state + .spacetime_client() + .execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + operation_id: operation_id.clone(), + action: "draft_foundation".to_string(), + payload_json: Some(payload_json), + submitted_at_micros: current_utc_micros(), + }) + .await + { + let message = error.to_string(); + 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; + } + }); +} + +async fn upsert_custom_world_draft_foundation_progress( + state: &AppState, + session_id: &str, + owner_user_id: &str, + operation_id: &str, + status: &str, + phase_label: &str, + phase_detail: &str, + progress: u32, + error_message: Option, +) -> Result { + state + .spacetime_client() + .upsert_custom_world_agent_operation_progress( + CustomWorldAgentOperationProgressRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + operation_id: operation_id.to_string(), + operation_type: "draft_foundation".to_string(), + operation_status: status.to_string(), + phase_label: phase_label.to_string(), + phase_detail: phase_detail.to_string(), + operation_progress: progress.min(100), + error_message, + updated_at_micros: current_utc_micros(), + }, + ) + .await +} + fn map_custom_world_library_entry_response( entry: CustomWorldLibraryEntryRecord, ) -> CustomWorldLibraryEntryResponse { diff --git a/server-rs/crates/api-server/src/custom_world_agent_turn.rs b/server-rs/crates/api-server/src/custom_world_agent_turn.rs index 1743c1c0..8ffa6d5d 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_turn.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_turn.rs @@ -5,6 +5,14 @@ use module_custom_world::{ use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; + +use crate::custom_world_rpg_draft_prompts::{ + BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, QUICK_FILL_EXTRA_RULES, + STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT, + extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk, + parse_json_response_text, parse_user_input_signal, render_chat_history_context, + render_current_anchor_context, render_dynamic_state_context, user_signal_rules, +}; use spacetime_client::{ CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentSessionRecord, @@ -42,7 +50,7 @@ pub(crate) struct CustomWorldAgentTurnResult { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PromptUserInputSignal { +pub(crate) enum PromptUserInputSignal { Rich, Normal, Sparse, @@ -51,14 +59,14 @@ enum PromptUserInputSignal { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PromptDriftRisk { +pub(crate) enum PromptDriftRisk { Low, Medium, High, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PromptConversationMode { +pub(crate) enum PromptConversationMode { Bootstrap, Expand, Compress, @@ -69,18 +77,18 @@ enum PromptConversationMode { #[derive(Clone, Debug)] #[allow(dead_code)] -struct PromptDynamicState { +pub(crate) struct PromptDynamicState { current_turn: u32, progress_percent: u32, - user_input_signal: PromptUserInputSignal, - drift_risk: PromptDriftRisk, + pub(crate) user_input_signal: PromptUserInputSignal, + pub(crate) drift_risk: PromptDriftRisk, quick_fill_requested: bool, - conversation_mode: PromptConversationMode, - judgement_summary: String, + pub(crate) conversation_mode: PromptConversationMode, + pub(crate) judgement_summary: String, } #[derive(Clone, Debug, Default)] -struct PromptDynamicStateInference { +pub(crate) struct PromptDynamicStateInference { user_input_signal: Option, drift_risk: Option, conversation_mode: Option, @@ -177,7 +185,7 @@ struct IconicElementValue { #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -struct EightAnchorContent { +pub(crate) struct EightAnchorContent { #[serde(default)] world_promise: Option, #[serde(default)] @@ -271,229 +279,6 @@ impl std::fmt::Display for CustomWorldTurnError { impl std::error::Error for CustomWorldTurnError {} -const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。 - -你正在和用户一起共创一个游戏世界。每一轮你都必须读取: -1. 当前完整设定结构 -2. 用户聊天记录 - -然后输出: -1. 一版新的完整设定结构 -2. 当前 progress 百分比 -3. 一段直接回复用户的话 - -你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 -你的输出会直接覆盖上一版设定结构。 - -你不是在做局部 patch。 -你不是在做解释报告。 -你不是在给开发者写分析。 -你是在同时完成: -1. 世界设定更新 -2. 当前推进程度判断 -3. 对用户的共创回复"#; - -const GLOBAL_HARD_RULES: &str = r#"全局硬约束: - -1. 必须输出完整的设定结构,而不是只输出变化部分。 -2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 -3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 -4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 -5. progressPercent 最低为 0,不允许为负数。 -6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 -7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 -8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 -9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 -10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 -11. 你输出的 JSON 必须可以被直接解析。 -12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#; - -const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。 - -这表示用户接受你基于当前方向自动补完剩余设定。 - -本轮要求: -1. 不要再继续提问 -2. 直接输出一版尽量完整的设定结构 -3. progressPercent 直接输出为 100 -4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#; - -const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。 -你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 - -你必须综合以下信息判断: -1. 当前轮次 currentTurn -2. 当前完成度 progressPercent -3. 用户是否要求自动补全 quickFillRequested -4. 当前完整设定结构 -5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 - -你需要输出 4 个字段: -1. userInputSignal:只能是 rich / normal / sparse / correction / delegate -2. driftRisk:只能是 low / medium / high -3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing -4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 - -请按下面的语义判断。 - -一、userInputSignal 定义 -1. rich -- 用户这一轮给了多条可直接落地的有效信息 -- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 -- 正式生成时应优先高密度吸收,不要只更新一个点 - -2. normal -- 用户在顺着当前方向做正常补充 -- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 -- 正式生成时应稳定推进并自然接住用户内容 - -3. sparse -- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 -- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 -- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 -- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 - -4. correction -- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 -- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction -- correction 的优先级高于 rich 和 normal - -5. delegate -- 用户把部分决定权交给系统 -- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” -- delegate 关注的是授权关系,不只是信息多寡 - -二、driftRisk 定义 -1. low -- 当前轮输入与已有方向基本一致 -- 没有明显改口或冲突 - -2. medium -- 当前轮带来一定方向变化或扩张 -- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 - -3. high -- 用户明确纠偏、改口、替换方向,或最近多轮反复修正 -- 这时最重要的是防止旧方向重新回流到正式生成结果里 - -三、conversationMode 选择原则 -1. bootstrap -- 适用于前期、信息少、核心方向未稳定 -- replyText 更适合低压力确认和单点启发 - -2. expand -- 适用于方向已成形,正在顺着现有路线继续补充 -- replyText 更适合总结已接住的内容并往前推一步 - -3. compress -- 适用于中后段,已有骨架,需要开始收束 -- replyText 更适合聚焦最关键缺口,而不是继续开支线 - -4. repair_direction -- 适用于用户正在纠偏 -- replyText 更适合先承认修正,再沿修正后的方向继续推进 - -5. force_complete -- 适用于用户明确要求自动补全 -- replyText 不再提问,而应给出完成感和下一步引导 - -6. closing -- 适用于接近完成但并非强制一键补全 -- replyText 更像确认与收束,而不是前期式探索 - -四、优先级规则 -1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete -2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction -3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate -4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 - -五、关于 replyText 风格的专门判断要求 -1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 -2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 -3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 -4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 -5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 - -六、关于 replyText 用语的硬约束 -1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 -2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 -3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 -4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 -5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 - -七、关于 judgementSummary 的写法 -1. 必须简洁,不要写成长篇分析 -2. 必须直接服务于下一轮正式生成 -3. 最好同时包含两层信息: -- 为什么这么判断 -- 正式生成时最该优先做什么,或最该避免什么 - -八、硬性约束 -1. 只能输出 JSON,不能输出解释、代码块或额外说明 -2. 不能发明上下文里不存在的设定事实 -3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” -4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 -5. judgementSummary 必须是中文 -6. 输出值必须严格落在给定枚举中"#; - -const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "userInputSignal": "normal", - "driftRisk": "low", - "conversationMode": "expand", - "judgementSummary": "" -}"#; - -const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: -{ - "replyText": "", - "progressPercent": 0, - "nextAnchorContent": { - "worldPromise": { - "hook": "", - "differentiator": "", - "desiredExperience": "" - }, - "playerFantasy": { - "playerRole": "", - "corePursuit": "", - "fearOfLoss": "" - }, - "themeBoundary": { - "toneKeywords": [], - "aestheticDirectives": [], - "forbiddenDirectives": [] - }, - "playerEntryPoint": { - "openingIdentity": "", - "openingProblem": "", - "entryMotivation": "" - }, - "coreConflict": { - "surfaceConflicts": [], - "hiddenCrisis": "", - "firstTouchedConflict": "" - }, - "keyRelationships": [ - { - "pairs": "", - "relationshipType": "", - "secretOrCost": "" - } - ], - "hiddenLines": { - "hiddenTruths": [], - "misdirectionHints": [], - "revealPacing": "" - }, - "iconicElements": { - "iconicMotifs": [], - "institutionsOrArtifacts": [], - "hardRules": [] - } - } -}"#; - pub(crate) async fn run_custom_world_agent_turn( request: CustomWorldAgentTurnRequest<'_>, on_reply_update: F, @@ -1679,293 +1464,6 @@ fn summarize_dynamic_state( ) } -fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String { - format!( - "上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}", - dynamic_state.user_input_signal.as_str(), - dynamic_state.drift_risk.as_str(), - dynamic_state.conversation_mode.as_str(), - dynamic_state.judgement_summary - ) -} - -fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String { - format!( - "当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}", - serde_json::to_string_pretty(anchor_content) - .unwrap_or_else(|_| empty_agent_anchor_content_json()) - ) -} - -fn render_chat_history_context(chat_history: &[JsonValue]) -> String { - format!( - "以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}", - serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string()) - ) -} - -fn parse_json_response_text(text: &str) -> Result { - let trimmed = text.trim(); - if let Some(start) = trimmed.find('{') - && let Some(end) = trimmed.rfind('}') - && end > start - { - return serde_json::from_str::(&trimmed[start..=end]); - } - serde_json::from_str::(trimmed) -} - -fn extract_reply_text_from_partial_json(text: &str) -> Option { - let key_index = text.find("\"replyText\"")?; - let colon_index = text[key_index..].find(':')? + key_index; - let mut cursor = colon_index + 1; - while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() { - cursor += 1; - } - if text.as_bytes().get(cursor).copied() != Some(b'"') { - return None; - } - cursor += 1; - let mut decoded = String::new(); - let remainder = text.get(cursor..)?; - let mut characters = remainder.chars().peekable(); - while let Some(current) = characters.next() { - if current == '"' { - return Some(decoded); - } - if current == '\\' { - let escaped = characters.next()?; - match escaped { - '"' => decoded.push('"'), - '\\' => decoded.push('\\'), - '/' => decoded.push('/'), - 'b' => decoded.push('\u{0008}'), - 'f' => decoded.push('\u{000C}'), - 'n' => decoded.push('\n'), - 'r' => decoded.push('\r'), - 't' => decoded.push('\t'), - 'u' => { - let mut hex = String::new(); - for _ in 0..4 { - hex.push(characters.next()?); - } - if let Ok(code) = u16::from_str_radix(hex.as_str(), 16) - && let Some(character) = char::from_u32(code as u32) - { - decoded.push(character); - } - } - other => decoded.push(other), - } - continue; - } - decoded.push(current); - } - Some(decoded) -} - -fn parse_user_input_signal(value: Option<&JsonValue>) -> Option { - match value.and_then(JsonValue::as_str)? { - "rich" => Some(PromptUserInputSignal::Rich), - "normal" => Some(PromptUserInputSignal::Normal), - "sparse" => Some(PromptUserInputSignal::Sparse), - "correction" => Some(PromptUserInputSignal::Correction), - "delegate" => Some(PromptUserInputSignal::Delegate), - _ => None, - } -} - -fn parse_drift_risk(value: Option<&JsonValue>) -> Option { - match value.and_then(JsonValue::as_str)? { - "low" => Some(PromptDriftRisk::Low), - "medium" => Some(PromptDriftRisk::Medium), - "high" => Some(PromptDriftRisk::High), - _ => None, - } -} - -fn parse_conversation_mode(value: Option<&JsonValue>) -> Option { - match value.and_then(JsonValue::as_str)? { - "bootstrap" => Some(PromptConversationMode::Bootstrap), - "expand" => Some(PromptConversationMode::Expand), - "compress" => Some(PromptConversationMode::Compress), - "repair_direction" => Some(PromptConversationMode::RepairDirection), - "force_complete" => Some(PromptConversationMode::ForceComplete), - "closing" => Some(PromptConversationMode::Closing), - _ => None, - } -} - -fn mode_rules(mode: PromptConversationMode) -> &'static str { - match mode { - PromptConversationMode::Bootstrap => { - r#"当前模式:bootstrap - -目标: -1. 先把世界的基本方向抓住 -2. 不要一次塞太多新设定 -3. 回复要降低用户开口压力 - -本轮行为要求: -1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 -2. 如果用户信息很少,不要强行把整套结构一次补满 -3. replyText 要像共创搭档,而不是像审问 -4. 默认只推进一个最关键的问题方向 -5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 -6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 -7. 不要把问题问得像表单采集,不要一口气追问多个维度 - -用户体验要求: -1. 让用户觉得“现在很容易继续往下说” -2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 -3. replyText 最好短、稳、可接话 -4. 如果用户信息很少,也不要显得冷淡或机械"# - } - PromptConversationMode::Expand => { - r#"当前模式:expand - -目标: -1. 在保持现有方向的前提下,把设定结构逐步补全 -2. 尽量让一轮输入覆盖多个关键维度 - -本轮行为要求: -1. 继续保留上一版里仍成立的设定 -2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 -3. replyText 要明确体现“你已经理解了哪些内容” -4. 不要突然大幅改写已经成形的世界 -5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 -6. 可以适度替用户整理,但不要把回复写成总结报告 -7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 - -用户体验要求: -1. 让用户感到“我刚说的内容都被接住了” -2. 回复里可以带一点顺势整理感,但不要太像会议纪要 -3. 不要无视用户刚提供的高价值细节 -4. 不要让用户觉得系统在自顾自重写世界"# - } - PromptConversationMode::Compress => { - r#"当前模式:compress - -目标: -1. 开始收束当前设定 -2. 减少无效发散 -3. 让 progress 更接近可进入下一阶段 - -本轮行为要求: -1. 新的设定结构优先保留稳定内容,不要无端重写 -2. 对用户本轮输入做高密度吸收 -3. replyText 要更聚焦,不要绕圈 -4. 默认只推进当前最影响 completion 的一步 -5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 -6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist -7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 - -用户体验要求: -1. 让用户感觉世界正在变得更稳,而不是越来越散 -2. 让推进感更明确,但不要显得催促 -3. 回复语气应更笃定一些,减少反复横跳 -4. 不要把用户刚补进来的细节又冲淡掉"# - } - PromptConversationMode::RepairDirection => { - r#"当前模式:repair_direction - -目标: -1. 处理用户对既有设定的修正 -2. 避免世界方向飘散或自相矛盾 - -本轮行为要求: -1. 如果用户明确改口,新的设定结构必须体现修正后的方向 -2. 对已经不再成立的旧设定,不要机械保留 -3. progressPercent 可以停滞,也可以小幅回落,但不能为负 -4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 -5. 先处理“改掉什么”,再决定“往哪里继续推” -6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 -7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 - -用户体验要求: -1. 让用户感到“我刚刚的纠偏真的生效了” -2. 不要和用户辩论旧方案为什么也行 -3. 不要表现出对修正的不情愿 -4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"# - } - PromptConversationMode::ForceComplete => { - r#"当前模式:force_complete - -目标: -1. 基于当前方向直接补齐剩余设定 -2. 生成一版尽量完整、可进入下一阶段的设定结构 -3. 结束当前收集阶段 - -本轮行为要求: -1. 尽量保留已经形成的世界方向 -2. 对明显缺失的关键维度进行合理补全 -3. 不要继续拉长聊天,不要再追问用户 -4. progressPercent 直接输出为 100 -5. replyText 要自然引导用户点击“生成游戏设定草稿” -6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 -7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 -8. replyText 更像阶段完成提示,不再像继续采集信息的对话 - -用户体验要求: -1. 让用户感到“系统已经帮我把能补的补好了” -2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 -3. 回复要有完成感,但不要太官话 -4. 清楚告诉用户下一步可以做什么"# - } - PromptConversationMode::Closing => { - r#"当前模式:closing - -目标: -1. 尽量形成一版可用的设定底子 -2. 不再继续发散新世界观 - -本轮行为要求: -1. 优先收束,而不是扩写 -2. 不要大改已经成形的核心设定 -3. progressPercent 接近完成时,replyText 要更像确认与推进 -4. 如果用户没有大改方向,尽量让下一版内容更稳定 -5. 可以轻微补足缺口,但不要再大开新支线 -6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 -7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 - -用户体验要求: -1. 让用户感觉作品已经快成了,而不是还在无穷试探 -2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 -3. 保持留白感,不要把所有东西都一次说死 -4. 让用户自然过渡到下一阶段,而不是突然被切断对话"# - } - } -} - -fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str { - match signal { - PromptUserInputSignal::Rich => { - r#"本轮用户输入信息密度高。 -请尽量从这一轮里提取多个锚点,不要只更新单一方向。 -如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"# - } - PromptUserInputSignal::Normal => { - r#"本轮用户输入为正常补充。 -请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"# - } - PromptUserInputSignal::Sparse => { - r#"本轮用户输入较少或较虚。 -请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 -replyText 要让用户容易继续往下说。"# - } - PromptUserInputSignal::Correction => { - r#"本轮用户在修正或推翻旧设定。 -请优先吸收修正,不要机械复读旧版本。 -新的完整设定结构必须以修正后的方向为准。"# - } - PromptUserInputSignal::Delegate => { - r#"本轮用户把部分决定权交给你。 -你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 -新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"# - } - } -} - fn latest_user_text(chat_history: &[JsonValue]) -> String { chat_history .iter() @@ -2075,7 +1573,7 @@ fn serialize_json(value: &JsonValue, fallback: &str) -> String { } impl PromptUserInputSignal { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Rich => "rich", Self::Normal => "normal", @@ -2087,7 +1585,7 @@ impl PromptUserInputSignal { } impl PromptDriftRisk { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Low => "low", Self::Medium => "medium", @@ -2097,7 +1595,7 @@ impl PromptDriftRisk { } impl PromptConversationMode { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Bootstrap => "bootstrap", Self::Expand => "expand", @@ -2111,7 +1609,7 @@ impl PromptConversationMode { #[cfg(test)] mod tests { - use super::extract_reply_text_from_partial_json; + use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json; #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { 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 655bc5a5..6da5d704 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -27,8 +27,15 @@ use tokio::time::sleep; use webp::Encoder as WebpEncoder; use crate::{ - api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - request_context::RequestContext, state::AppState, + api_response::json_success_body, + auth::AuthenticatedAccessToken, + custom_world_result_prompts::{ + build_result_entity_system_prompt, build_result_entity_user_prompt, + build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt, + }, + http_error::AppError, + request_context::RequestContext, + state::AppState, }; #[derive(Clone, Debug, Deserialize)] @@ -883,18 +890,8 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: return fallback; }; let request = LlmTextRequest::new(vec![ - LlmMessage::system( - "你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。", - ), - LlmMessage::user( - json!({ - "task": "generate_custom_world_entity", - "kind": kind, - "profile": profile, - "fallback": fallback, - }) - .to_string(), - ), + LlmMessage::system(build_result_entity_system_prompt()), + LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)), ]); llm_client @@ -915,18 +912,12 @@ async fn generate_scene_npc_with_fallback( return fallback; }; let request = LlmTextRequest::new(vec![ - LlmMessage::system( - "你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。", - ), - LlmMessage::user( - json!({ - "task": "generate_custom_world_scene_npc", - "landmarkId": landmark_id, - "profile": profile, - "fallback": fallback, - }) - .to_string(), - ), + LlmMessage::system(build_result_scene_npc_system_prompt()), + LlmMessage::user(build_result_scene_npc_user_prompt( + profile, + landmark_id, + &fallback, + )), ]); llm_client diff --git a/server-rs/crates/api-server/src/custom_world_asset_prompts.rs b/server-rs/crates/api-server/src/custom_world_asset_prompts.rs new file mode 100644 index 00000000..5a2ed40f --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_asset_prompts.rs @@ -0,0 +1,356 @@ +use crate::character_animation_assets::find_motion_template; +use shared_contracts::assets::CharacterAnimationStrategy; + +/// 自定义世界角色主图提示词脚本。 +pub(crate) fn build_character_visual_prompt( + prompt_text: &str, + character_brief_text: Option<&str>, +) -> String { + let merged = [character_brief_text.unwrap_or_default(), prompt_text] + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"); + + format!( + "{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。", + if merged.is_empty() { + "自定义世界角色,服装完整,姿态自然。" + } else { + merged.as_str() + } + ) +} + +/// 自定义世界角色主图负面提示词脚本。 +pub(crate) fn build_character_visual_negative_prompt() -> String { + [ + "正面视角", + "左朝向", + "完全 90 度纯右视图", + "镜头透视", + "半身像", + "脚被裁切", + "头顶被裁切", + "多角色", + "复杂背景", + "建筑场景", + "漂浮物", + "烟雾环境", + "武器消失", + "武器换手", + "额外手臂", + "额外腿", + "服装变化", + "脸部变化", + "模糊", + "运动模糊", + "文字", + "水印", + "UI 元素", + "软萌 Q版大头贴", + "儿童绘本风", + "厚涂插画感", + "低对比柔边", + ] + .join(",") +} + +pub(crate) fn build_character_animation_prompt( + strategy: &CharacterAnimationStrategy, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + animation: &str, + frame_count: u32, + fps: u32, + duration_seconds: u32, + loop_: bool, + use_chroma_key: bool, +) -> String { + match strategy { + CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + ), + CharacterAnimationStrategy::ImageSequence => { + build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key) + } + CharacterAnimationStrategy::MotionTransfer + | CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt( + animation, + prompt_text, + character_brief_text, + action_template_id, + loop_, + use_chroma_key, + fps, + duration_seconds, + ), + } +} + +fn build_image_sequence_prompt( + animation: &str, + prompt_text: &str, + frame_count: u32, + use_chroma_key: bool, +) -> String { + [ + format!( + "同一角色连续 {} 帧动作序列,动作主题是 {}。", + frame_count, animation + ), + "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), + "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), + if use_chroma_key { + "纯绿色背景,无地面装饰,方便后期抠像。".to_string() + } else { + "背景尽量纯净,避免复杂场景。".to_string() + }, + prompt_text.trim().to_string(), + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_npc_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, + fps: u32, + duration_seconds: u32, +) -> String { + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + let loop_rule = if loop_ { + "这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。" + .to_string() + } else if animation == "die" { + "这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。" + .to_string() + } else { + "这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string() + }; + + if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) { + return [ + format!( + "单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。", + template.animation + ), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + format!("动作补充:{}。", template.prompt_suffix), + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" "); + } + + [ + format!("单人 NPC 全身动作视频,动作主题是 {}。", animation), + "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), + "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + if action_detail_text.is_empty() { + String::new() + } else { + action_detail_text + }, + format!( + "目标帧率 {} fps,时长约 {} 秒。", + fps.clamp(1, 60), + duration_seconds.clamp(1, 8) + ), + loop_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +fn build_ark_character_animation_prompt( + animation: &str, + prompt_text: &str, + character_brief_text: Option<&str>, + action_template_id: Option<&str>, + loop_: bool, + use_chroma_key: bool, +) -> String { + let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_"); + let normalized_animation_name = if normalized_animation_name.is_empty() { + "idle".to_string() + } else { + normalized_animation_name + }; + let character_brief = build_compact_animation_character_brief(character_brief_text); + let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140); + let frame_rule = if loop_ { + "首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。".to_string() + } else { + "首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。".to_string() + }; + + if let Some(template) = action_template_id.and_then(find_motion_template) { + return [ + format!( + "单人 NPC 全身动作视频,动作英文名是 {}。角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。", + normalized_animation_name + ), + "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + format!("动作补充:{}。", template.prompt_suffix), + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + frame_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" "); + } + + [ + format!( + "单人 NPC 全身动作视频,动作英文名是 {}。", + normalized_animation_name + ), + "角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。" + .to_string(), + "动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(), + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + } else { + "背景简洁纯净,无复杂场景。".to_string() + }, + if character_brief.is_empty() { + String::new() + } else { + format!("角色设定:{}。", character_brief) + }, + if action_detail_text.is_empty() { + String::new() + } else { + format!("动作细节:{}。", action_detail_text) + }, + frame_rule, + ] + .into_iter() + .filter(|value| !value.trim().is_empty()) + .collect::>() + .join(" ") +} + +pub(crate) fn build_fallback_moderation_safe_animation_prompt( + animation: &str, + loop_: bool, + use_chroma_key: bool, +) -> String { + [ + format!("单人全身角色动作视频,动作主题是 {}。", animation), + "角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(), + if loop_ { + "循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string() + } else { + "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() + }, + if use_chroma_key { + "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() + } else { + "背景简洁纯净。".to_string() + }, + ] + .join(" ") +} + +fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String { + value + .replace(char::is_whitespace, " ") + .replace("血浆", "") + .replace("喷血", "") + .replace("鲜血", "") + .replace("断肢", "") + .replace("斩首", "") + .replace("裸体", "") + .replace("裸露", "") + .replace("色情", "") + .replace("性交", "") + .replace("死亡", "倒地结束") + .replace("死去", "倒地结束") + .replace("击杀", "倒地结束") + .replace("受击", "失衡") + .replace("受伤", "失衡") + .replace("砍杀", "挥击") + .replace("斩击", "挥击") + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_length) + .collect::() + .trim() + .to_string() +} + +fn build_compact_animation_character_brief(value: Option<&str>) -> String { + let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160); + if normalized.is_empty() { + return String::new(); + } + normalized + .split(['/', '|', '\n', ',', ',', '。', ';', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(4) + .collect::>() + .join(",") +} 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 26fcba2f..518c3ccf 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 @@ -15,11 +15,25 @@ pub enum DraftFoundationPayloadError { InvalidGeneratedDraft(String), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldFoundationDraftProgress { + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, +} + pub async fn generate_custom_world_foundation_draft( llm_client: &LlmClient, session: &CustomWorldAgentSessionRecord, + mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send, ) -> Result { let setting_text = build_foundation_generation_seed_text(session); + emit_foundation_draft_progress( + &mut on_progress, + "整理世界骨架", + "正在根据创作者锚点生成第一版世界框架。", + 12, + ); let mut framework = request_foundation_json_stage( llm_client, build_custom_world_framework_prompt(setting_text.as_str()), @@ -36,6 +50,8 @@ pub async fn generate_custom_world_foundation_draft( &framework, "playable", FOUNDATION_DRAFT_PLAYABLE_COUNT, + (16, 30), + &mut on_progress, ) .await?; framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone()); @@ -45,6 +61,8 @@ pub async fn generate_custom_world_foundation_draft( &framework, "story", FOUNDATION_DRAFT_STORY_COUNT, + (30, 44), + &mut on_progress, ) .await?; framework["storyNpcs"] = JsonValue::Array(story_outlines.clone()); @@ -53,6 +71,8 @@ pub async fn generate_custom_world_foundation_draft( llm_client, &framework, FOUNDATION_DRAFT_LANDMARK_COUNT, + (44, 56), + &mut on_progress, ) .await?; framework["landmarks"] = JsonValue::Array(landmark_seeds.clone()); @@ -62,6 +82,8 @@ pub async fn generate_custom_world_foundation_draft( &framework, &story_outlines, &landmark_seeds, + (56, 66), + &mut on_progress, ) .await?; framework["landmarks"] = JsonValue::Array(landmarks.clone()); @@ -72,6 +94,8 @@ pub async fn generate_custom_world_foundation_draft( "playable", &playable_outlines, "narrative", + (66, 76), + &mut on_progress, ) .await?; let playable_detailed = expand_foundation_role_entries( @@ -80,6 +104,8 @@ pub async fn generate_custom_world_foundation_draft( "playable", &playable_narrative, "dossier", + (76, 84), + &mut on_progress, ) .await?; let story_narrative = expand_foundation_role_entries( @@ -88,6 +114,8 @@ pub async fn generate_custom_world_foundation_draft( "story", &story_outlines, "narrative", + (84, 92), + &mut on_progress, ) .await?; let story_detailed = expand_foundation_role_entries( @@ -96,9 +124,18 @@ pub async fn generate_custom_world_foundation_draft( "story", &story_narrative, "dossier", + (92, 96), + &mut on_progress, ) .await?; + emit_foundation_draft_progress( + &mut on_progress, + "编译世界底稿", + "正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。", + 97, + ); + let draft_profile = build_foundation_draft_profile_from_framework( framework, playable_detailed, @@ -166,6 +203,8 @@ async fn generate_foundation_role_outline_entries( framework: &JsonValue, role_type: &str, total_count: usize, + progress_range: (u32, u32), + on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); let planned_batch_count = total_count @@ -178,6 +217,24 @@ async fn generate_foundation_role_outline_entries( let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE); let forbidden_names = names_from_entries(&merged_entries); + let role_label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + emit_foundation_draft_progress( + on_progress, + format!("生成{role_label}").as_str(), + format!( + "正在生成{role_label}第 {} / {} 批,当前已完成 {}/{}。", + batch_index + 1, + planned_batch_count, + merged_entries.len(), + total_count, + ) + .as_str(), + to_batch_progress(progress_range, merged_entries.len(), total_count), + ); let raw = request_foundation_json_stage( llm_client, build_custom_world_role_outline_batch_prompt( @@ -210,13 +267,27 @@ async fn generate_foundation_role_outline_entries( let key = role_key(role_type); merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count)); } - Ok(merged_entries.into_iter().take(total_count).collect()) + let merged_entries: Vec = merged_entries.into_iter().take(total_count).collect(); + let role_label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + emit_foundation_draft_progress( + on_progress, + format!("生成{role_label}").as_str(), + format!("{role_label}已经整理完成,共 {} 个。", merged_entries.len()).as_str(), + progress_range.1, + ); + Ok(merged_entries) } async fn generate_foundation_landmark_seed_entries( llm_client: &LlmClient, framework: &JsonValue, total_count: usize, + progress_range: (u32, u32), + on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); let planned_batch_count = total_count.div_ceil(FOUNDATION_LANDMARK_BATCH_SIZE).max(1); @@ -226,6 +297,19 @@ async fn generate_foundation_landmark_seed_entries( } let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE); let forbidden_names = names_from_entries(&merged_entries); + emit_foundation_draft_progress( + on_progress, + "生成关键场景", + format!( + "正在生成关键场景第 {} / {} 批,当前已完成 {}/{}。", + batch_index + 1, + planned_batch_count, + merged_entries.len(), + total_count, + ) + .as_str(), + to_batch_progress(progress_range, merged_entries.len(), total_count), + ); let raw = request_foundation_json_stage( llm_client, build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names), @@ -247,7 +331,14 @@ async fn generate_foundation_landmark_seed_entries( .await?; merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count)); } - Ok(merged_entries.into_iter().take(total_count).collect()) + let merged_entries: Vec = merged_entries.into_iter().take(total_count).collect(); + emit_foundation_draft_progress( + on_progress, + "生成关键场景", + format!("关键场景骨架已整理完成,共 {} 个。", merged_entries.len()).as_str(), + progress_range.1, + ); + Ok(merged_entries) } async fn expand_foundation_landmark_network_entries( @@ -255,12 +346,28 @@ async fn expand_foundation_landmark_network_entries( framework: &JsonValue, story_npcs: &[JsonValue], base_entries: &[JsonValue], + progress_range: (u32, u32), + on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); - for (batch_index, batch) in base_entries + let batches: Vec<&[JsonValue]> = base_entries .chunks(FOUNDATION_LANDMARK_BATCH_SIZE) - .enumerate() - { + .collect(); + let mut processed_count = 0usize; + for (batch_index, batch) in batches.iter().enumerate() { + emit_foundation_draft_progress( + on_progress, + "建立场景连接", + format!( + "正在补全场景连接第 {} / {} 批,当前已完成 {}/{}。", + batch_index + 1, + batches.len(), + processed_count, + base_entries.len(), + ) + .as_str(), + to_batch_progress(progress_range, processed_count, base_entries.len()), + ); let raw = request_foundation_json_stage( llm_client, build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch), @@ -284,7 +391,16 @@ async fn expand_foundation_landmark_network_entries( ) .await?; merged_entries.extend(array_field(&raw, "landmarks")); + processed_count = processed_count + .saturating_add(batch.len()) + .min(base_entries.len()); } + emit_foundation_draft_progress( + on_progress, + "建立场景连接", + "关键场景的角色分布与路径连接已经整理完成。", + progress_range.1, + ); Ok(merge_entries_by_name(base_entries, &merged_entries)) } @@ -294,13 +410,39 @@ async fn expand_foundation_role_entries( role_type: &str, base_entries: &[JsonValue], stage: &str, + progress_range: (u32, u32), + on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); - for (batch_index, batch) in base_entries + let batches: Vec<&[JsonValue]> = base_entries .chunks(FOUNDATION_ROLE_DETAIL_BATCH_SIZE) - .enumerate() - { + .collect(); + let mut processed_count = 0usize; + for (batch_index, batch) in batches.iter().enumerate() { let expected_names = names_from_entries(batch); + let role_label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + let stage_label = if stage == "narrative" { + "叙事基础" + } else { + "档案细节" + }; + emit_foundation_draft_progress( + on_progress, + format!("补全{role_label}{stage_label}").as_str(), + format!( + "正在补全{role_label}{stage_label}第 {} / {} 批,当前已完成 {}/{}。", + batch_index + 1, + batches.len(), + processed_count, + base_entries.len(), + ) + .as_str(), + to_batch_progress(progress_range, processed_count, base_entries.len()), + ); let raw = request_foundation_json_stage( llm_client, build_custom_world_role_batch_prompt(framework, role_type, batch, stage), @@ -326,9 +468,51 @@ async fn expand_foundation_role_entries( ) .await?; merged_entries.extend(array_field(&raw, role_key(role_type))); + processed_count = processed_count + .saturating_add(batch.len()) + .min(base_entries.len()); } + let role_label = if role_type == "playable" { + "可扮演角色" + } else { + "场景角色" + }; + let stage_label = if stage == "narrative" { + "叙事基础" + } else { + "档案细节" + }; + emit_foundation_draft_progress( + on_progress, + format!("补全{role_label}{stage_label}").as_str(), + format!("{role_label}{stage_label}已经整理完成。").as_str(), + progress_range.1, + ); Ok(merge_entries_by_name(base_entries, &merged_entries)) } + +fn emit_foundation_draft_progress( + on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), + phase_label: &str, + phase_detail: &str, + progress: u32, +) { + on_progress(CustomWorldFoundationDraftProgress { + phase_label: phase_label.to_string(), + phase_detail: phase_detail.to_string(), + progress: progress.min(100), + }); +} + +fn to_batch_progress(progress_range: (u32, u32), completed: usize, total: usize) -> u32 { + if total == 0 { + return progress_range.1; + } + let start = progress_range.0 as f64; + let end = progress_range.1 as f64; + let ratio = (completed as f64 / total as f64).clamp(0.0, 1.0); + (start + (end - start) * ratio).round().clamp(0.0, 100.0) as u32 +} // foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。 pub fn build_draft_foundation_action_payload_json( payload: &ExecuteCustomWorldAgentActionRequest, @@ -1528,7 +1712,7 @@ mod tests { let llm_client = build_test_llm_client(server_url); let session = build_test_session(); - let result = generate_custom_world_foundation_draft(&llm_client, &session) + let result = generate_custom_world_foundation_draft(&llm_client, &session, |_| {}) .await .expect("draft generation should succeed"); let draft_profile = serde_json::from_str::(&result.draft_profile_json) diff --git a/server-rs/crates/api-server/src/custom_world_result_prompts.rs b/server-rs/crates/api-server/src/custom_world_result_prompts.rs new file mode 100644 index 00000000..7175975d --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_result_prompts.rs @@ -0,0 +1,39 @@ +use serde_json::{Value, json}; + +/// 结果页新增可扮演角色 / 场景角色 / 场景的提示词脚本。 +/// 这里只生成 LLM 可审计输入,不处理 fallback,避免提示词规则和业务兜底混在一起。 +pub(crate) fn build_result_entity_system_prompt() -> &'static str { + "你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。" +} + +pub(crate) fn build_result_entity_user_prompt( + profile: &Value, + kind: &str, + fallback: &Value, +) -> String { + json!({ + "task": "generate_custom_world_entity", + "kind": kind, + "profile": profile, + "fallback": fallback, + }) + .to_string() +} + +pub(crate) fn build_result_scene_npc_system_prompt() -> &'static str { + "你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。" +} + +pub(crate) fn build_result_scene_npc_user_prompt( + profile: &Value, + landmark_id: &str, + fallback: &Value, +) -> String { + json!({ + "task": "generate_custom_world_scene_npc", + "landmarkId": landmark_id, + "profile": profile, + "fallback": fallback, + }) + .to_string() +} diff --git a/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs b/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs new file mode 100644 index 00000000..4bc56e8c --- /dev/null +++ b/server-rs/crates/api-server/src/custom_world_rpg_draft_prompts.rs @@ -0,0 +1,515 @@ +use crate::custom_world_agent_turn::{ + EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState, + PromptUserInputSignal, +}; +use module_custom_world::empty_agent_anchor_content_json; +use serde_json::Value as JsonValue; +pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。 + +你正在和用户一起共创一个游戏世界。每一轮你都必须读取: +1. 当前完整设定结构 +2. 用户聊天记录 + +然后输出: +1. 一版新的完整设定结构 +2. 当前 progress 百分比 +3. 一段直接回复用户的话 + +你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 +你的输出会直接覆盖上一版设定结构。 + +你不是在做局部 patch。 +你不是在做解释报告。 +你不是在给开发者写分析。 +你是在同时完成: +1. 世界设定更新 +2. 当前推进程度判断 +3. 对用户的共创回复"#; + +pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束: + +1. 必须输出完整的设定结构,而不是只输出变化部分。 +2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 +3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 +4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 +5. progressPercent 最低为 0,不允许为负数。 +6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 +7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 +8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 +9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 +10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 +11. 你输出的 JSON 必须可以被直接解析。 +12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#; + +pub(crate) const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。 + +这表示用户接受你基于当前方向自动补完剩余设定。 + +本轮要求: +1. 不要再继续提问 +2. 直接输出一版尽量完整的设定结构 +3. progressPercent 直接输出为 100 +4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#; + +pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。 +你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 + +你必须综合以下信息判断: +1. 当前轮次 currentTurn +2. 当前完成度 progressPercent +3. 用户是否要求自动补全 quickFillRequested +4. 当前完整设定结构 +5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 + +你需要输出 4 个字段: +1. userInputSignal:只能是 rich / normal / sparse / correction / delegate +2. driftRisk:只能是 low / medium / high +3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing +4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 + +请按下面的语义判断。 + +一、userInputSignal 定义 +1. rich +- 用户这一轮给了多条可直接落地的有效信息 +- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 +- 正式生成时应优先高密度吸收,不要只更新一个点 + +2. normal +- 用户在顺着当前方向做正常补充 +- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 +- 正式生成时应稳定推进并自然接住用户内容 + +3. sparse +- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 +- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 +- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 +- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 + +4. correction +- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 +- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction +- correction 的优先级高于 rich 和 normal + +5. delegate +- 用户把部分决定权交给系统 +- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” +- delegate 关注的是授权关系,不只是信息多寡 + +二、driftRisk 定义 +1. low +- 当前轮输入与已有方向基本一致 +- 没有明显改口或冲突 + +2. medium +- 当前轮带来一定方向变化或扩张 +- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 + +3. high +- 用户明确纠偏、改口、替换方向,或最近多轮反复修正 +- 这时最重要的是防止旧方向重新回流到正式生成结果里 + +三、conversationMode 选择原则 +1. bootstrap +- 适用于前期、信息少、核心方向未稳定 +- replyText 更适合低压力确认和单点启发 + +2. expand +- 适用于方向已成形,正在顺着现有路线继续补充 +- replyText 更适合总结已接住的内容并往前推一步 + +3. compress +- 适用于中后段,已有骨架,需要开始收束 +- replyText 更适合聚焦最关键缺口,而不是继续开支线 + +4. repair_direction +- 适用于用户正在纠偏 +- replyText 更适合先承认修正,再沿修正后的方向继续推进 + +5. force_complete +- 适用于用户明确要求自动补全 +- replyText 不再提问,而应给出完成感和下一步引导 + +6. closing +- 适用于接近完成但并非强制一键补全 +- replyText 更像确认与收束,而不是前期式探索 + +四、优先级规则 +1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete +2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction +3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate +4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 + +五、关于 replyText 风格的专门判断要求 +1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 +2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 +3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 +4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 +5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 + +六、关于 replyText 用语的硬约束 +1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 +2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 +3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 +4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 +5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 + +七、关于 judgementSummary 的写法 +1. 必须简洁,不要写成长篇分析 +2. 必须直接服务于下一轮正式生成 +3. 最好同时包含两层信息: +- 为什么这么判断 +- 正式生成时最该优先做什么,或最该避免什么 + +八、硬性约束 +1. 只能输出 JSON,不能输出解释、代码块或额外说明 +2. 不能发明上下文里不存在的设定事实 +3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” +4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 +5. judgementSummary 必须是中文 +6. 输出值必须严格落在给定枚举中"#; + +pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "userInputSignal": "normal", + "driftRisk": "low", + "conversationMode": "expand", + "judgementSummary": "" +}"#; + +pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: +{ + "replyText": "", + "progressPercent": 0, + "nextAnchorContent": { + "worldPromise": { + "hook": "", + "differentiator": "", + "desiredExperience": "" + }, + "playerFantasy": { + "playerRole": "", + "corePursuit": "", + "fearOfLoss": "" + }, + "themeBoundary": { + "toneKeywords": [], + "aestheticDirectives": [], + "forbiddenDirectives": [] + }, + "playerEntryPoint": { + "openingIdentity": "", + "openingProblem": "", + "entryMotivation": "" + }, + "coreConflict": { + "surfaceConflicts": [], + "hiddenCrisis": "", + "firstTouchedConflict": "" + }, + "keyRelationships": [ + { + "pairs": "", + "relationshipType": "", + "secretOrCost": "" + } + ], + "hiddenLines": { + "hiddenTruths": [], + "misdirectionHints": [], + "revealPacing": "" + }, + "iconicElements": { + "iconicMotifs": [], + "institutionsOrArtifacts": [], + "hardRules": [] + } + } +}"#; + +pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String { + format!( + "上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}", + dynamic_state.user_input_signal.as_str(), + dynamic_state.drift_risk.as_str(), + dynamic_state.conversation_mode.as_str(), + dynamic_state.judgement_summary + ) +} + +pub(crate) fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String { + format!( + "当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}", + serde_json::to_string_pretty(anchor_content) + .unwrap_or_else(|_| empty_agent_anchor_content_json()) + ) +} + +pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String { + format!( + "以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}", + serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string()) + ) +} + +pub(crate) fn parse_json_response_text(text: &str) -> Result { + let trimmed = text.trim(); + if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + return serde_json::from_str::(&trimmed[start..=end]); + } + serde_json::from_str::(trimmed) +} + +pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option { + let key_index = text.find("\"replyText\"")?; + let colon_index = text[key_index..].find(':')? + key_index; + let mut cursor = colon_index + 1; + while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() { + cursor += 1; + } + if text.as_bytes().get(cursor).copied() != Some(b'"') { + return None; + } + cursor += 1; + let mut decoded = String::new(); + let remainder = text.get(cursor..)?; + let mut characters = remainder.chars().peekable(); + while let Some(current) = characters.next() { + if current == '"' { + return Some(decoded); + } + if current == '\\' { + let escaped = characters.next()?; + match escaped { + '"' => decoded.push('"'), + '\\' => decoded.push('\\'), + '/' => decoded.push('/'), + 'b' => decoded.push('\u{0008}'), + 'f' => decoded.push('\u{000C}'), + 'n' => decoded.push('\n'), + 'r' => decoded.push('\r'), + 't' => decoded.push('\t'), + 'u' => { + let mut hex = String::new(); + for _ in 0..4 { + hex.push(characters.next()?); + } + if let Ok(code) = u16::from_str_radix(hex.as_str(), 16) + && let Some(character) = char::from_u32(code as u32) + { + decoded.push(character); + } + } + other => decoded.push(other), + } + continue; + } + decoded.push(current); + } + Some(decoded) +} + +pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option { + match value.and_then(JsonValue::as_str)? { + "rich" => Some(PromptUserInputSignal::Rich), + "normal" => Some(PromptUserInputSignal::Normal), + "sparse" => Some(PromptUserInputSignal::Sparse), + "correction" => Some(PromptUserInputSignal::Correction), + "delegate" => Some(PromptUserInputSignal::Delegate), + _ => None, + } +} + +pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option { + match value.and_then(JsonValue::as_str)? { + "low" => Some(PromptDriftRisk::Low), + "medium" => Some(PromptDriftRisk::Medium), + "high" => Some(PromptDriftRisk::High), + _ => None, + } +} + +pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option { + match value.and_then(JsonValue::as_str)? { + "bootstrap" => Some(PromptConversationMode::Bootstrap), + "expand" => Some(PromptConversationMode::Expand), + "compress" => Some(PromptConversationMode::Compress), + "repair_direction" => Some(PromptConversationMode::RepairDirection), + "force_complete" => Some(PromptConversationMode::ForceComplete), + "closing" => Some(PromptConversationMode::Closing), + _ => None, + } +} + +pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str { + match mode { + PromptConversationMode::Bootstrap => { + r#"当前模式:bootstrap + +目标: +1. 先把世界的基本方向抓住 +2. 不要一次塞太多新设定 +3. 回复要降低用户开口压力 + +本轮行为要求: +1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 +2. 如果用户信息很少,不要强行把整套结构一次补满 +3. replyText 要像共创搭档,而不是像审问 +4. 默认只推进一个最关键的问题方向 +5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 +6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 +7. 不要把问题问得像表单采集,不要一口气追问多个维度 + +用户体验要求: +1. 让用户觉得“现在很容易继续往下说” +2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 +3. replyText 最好短、稳、可接话 +4. 如果用户信息很少,也不要显得冷淡或机械"# + } + PromptConversationMode::Expand => { + r#"当前模式:expand + +目标: +1. 在保持现有方向的前提下,把设定结构逐步补全 +2. 尽量让一轮输入覆盖多个关键维度 + +本轮行为要求: +1. 继续保留上一版里仍成立的设定 +2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 +3. replyText 要明确体现“你已经理解了哪些内容” +4. 不要突然大幅改写已经成形的世界 +5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 +6. 可以适度替用户整理,但不要把回复写成总结报告 +7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 + +用户体验要求: +1. 让用户感到“我刚说的内容都被接住了” +2. 回复里可以带一点顺势整理感,但不要太像会议纪要 +3. 不要无视用户刚提供的高价值细节 +4. 不要让用户觉得系统在自顾自重写世界"# + } + PromptConversationMode::Compress => { + r#"当前模式:compress + +目标: +1. 开始收束当前设定 +2. 减少无效发散 +3. 让 progress 更接近可进入下一阶段 + +本轮行为要求: +1. 新的设定结构优先保留稳定内容,不要无端重写 +2. 对用户本轮输入做高密度吸收 +3. replyText 要更聚焦,不要绕圈 +4. 默认只推进当前最影响 completion 的一步 +5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 +6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist +7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 + +用户体验要求: +1. 让用户感觉世界正在变得更稳,而不是越来越散 +2. 让推进感更明确,但不要显得催促 +3. 回复语气应更笃定一些,减少反复横跳 +4. 不要把用户刚补进来的细节又冲淡掉"# + } + PromptConversationMode::RepairDirection => { + r#"当前模式:repair_direction + +目标: +1. 处理用户对既有设定的修正 +2. 避免世界方向飘散或自相矛盾 + +本轮行为要求: +1. 如果用户明确改口,新的设定结构必须体现修正后的方向 +2. 对已经不再成立的旧设定,不要机械保留 +3. progressPercent 可以停滞,也可以小幅回落,但不能为负 +4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 +5. 先处理“改掉什么”,再决定“往哪里继续推” +6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 +7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 + +用户体验要求: +1. 让用户感到“我刚刚的纠偏真的生效了” +2. 不要和用户辩论旧方案为什么也行 +3. 不要表现出对修正的不情愿 +4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"# + } + PromptConversationMode::ForceComplete => { + r#"当前模式:force_complete + +目标: +1. 基于当前方向直接补齐剩余设定 +2. 生成一版尽量完整、可进入下一阶段的设定结构 +3. 结束当前收集阶段 + +本轮行为要求: +1. 尽量保留已经形成的世界方向 +2. 对明显缺失的关键维度进行合理补全 +3. 不要继续拉长聊天,不要再追问用户 +4. progressPercent 直接输出为 100 +5. replyText 要自然引导用户点击“生成游戏设定草稿” +6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 +7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 +8. replyText 更像阶段完成提示,不再像继续采集信息的对话 + +用户体验要求: +1. 让用户感到“系统已经帮我把能补的补好了” +2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 +3. 回复要有完成感,但不要太官话 +4. 清楚告诉用户下一步可以做什么"# + } + PromptConversationMode::Closing => { + r#"当前模式:closing + +目标: +1. 尽量形成一版可用的设定底子 +2. 不再继续发散新世界观 + +本轮行为要求: +1. 优先收束,而不是扩写 +2. 不要大改已经成形的核心设定 +3. progressPercent 接近完成时,replyText 要更像确认与推进 +4. 如果用户没有大改方向,尽量让下一版内容更稳定 +5. 可以轻微补足缺口,但不要再大开新支线 +6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 +7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 + +用户体验要求: +1. 让用户感觉作品已经快成了,而不是还在无穷试探 +2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 +3. 保持留白感,不要把所有东西都一次说死 +4. 让用户自然过渡到下一阶段,而不是突然被切断对话"# + } + } +} + +pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str { + match signal { + PromptUserInputSignal::Rich => { + r#"本轮用户输入信息密度高。 +请尽量从这一轮里提取多个锚点,不要只更新单一方向。 +如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"# + } + PromptUserInputSignal::Normal => { + r#"本轮用户输入为正常补充。 +请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"# + } + PromptUserInputSignal::Sparse => { + r#"本轮用户输入较少或较虚。 +请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 +replyText 要让用户容易继续往下说。"# + } + PromptUserInputSignal::Correction => { + r#"本轮用户在修正或推翻旧设定。 +请优先吸收修正,不要机械复读旧版本。 +新的完整设定结构必须以修正后的方向为准。"# + } + PromptUserInputSignal::Delegate => { + r#"本轮用户把部分决定权交给你。 +你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 +新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"# + } + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 331748a9..1535b170 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -19,7 +19,10 @@ mod custom_world; mod custom_world_agent_entities; mod custom_world_agent_turn; mod custom_world_ai; +mod custom_world_asset_prompts; mod custom_world_foundation_draft; +mod custom_world_result_prompts; +mod custom_world_rpg_draft_prompts; mod error_middleware; mod health; mod http_error; diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index e17b2216..a99a8091 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -562,6 +562,21 @@ pub struct CustomWorldAgentOperationGetInput { pub operation_id: String, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldAgentOperationProgressInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub operation_type: RpgAgentOperationType, + pub operation_status: RpgAgentOperationStatus, + pub phase_label: String, + pub phase_detail: String, + pub operation_progress: u32, + pub error_message: Option, + pub updated_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentOperationProcedureResult { @@ -1205,6 +1220,24 @@ pub fn validate_custom_world_agent_operation_get_input( Ok(()) } +pub fn validate_custom_world_agent_operation_progress_input( + input: &CustomWorldAgentOperationProgressInput, +) -> Result<(), CustomWorldFieldError> { + validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + operation_id: input.operation_id.clone(), + })?; + validate_custom_world_agent_operation_fields( + &input.operation_id, + &input.session_id, + &input.phase_label, + input.operation_progress, + )?; + + Ok(()) +} + pub fn validate_custom_world_works_list_input( input: &CustomWorldWorksListInput, ) -> Result<(), CustomWorldFieldError> { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 63f2b08b..a06b4575 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -187,6 +187,7 @@ use crate::module_bindings::{ CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput, CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput, CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult, + CustomWorldAgentOperationProgressInput as BindingCustomWorldAgentOperationProgressInput, CustomWorldAgentOperationSnapshot as BindingCustomWorldAgentOperationSnapshot, CustomWorldAgentSessionCreateInput as BindingCustomWorldAgentSessionCreateInput, CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput, @@ -373,6 +374,7 @@ use crate::module_bindings::{ swap_puzzle_pieces_procedure::swap_puzzle_pieces as _, unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return as _, update_puzzle_work_procedure::update_puzzle_work as _, + upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress as _, upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return as _, upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return as _, upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return as _, @@ -1886,6 +1888,36 @@ impl SpacetimeClient { .await } + pub async fn upsert_custom_world_agent_operation_progress( + &self, + input: CustomWorldAgentOperationProgressRecordInput, + ) -> Result { + let procedure_input = BindingCustomWorldAgentOperationProgressInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + operation_type: map_custom_world_agent_operation_type(input.operation_type.as_str()), + operation_status: map_custom_world_agent_operation_status(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 + } + pub async fn list_platform_browse_history( &self, user_id: String, @@ -5274,6 +5306,24 @@ fn format_rpg_agent_operation_type( } } +fn map_custom_world_agent_operation_type(value: &str) -> crate::module_bindings::RpgAgentOperationType { + match value.trim() { + "draft_foundation" => crate::module_bindings::RpgAgentOperationType::DraftFoundation, + "update_draft_card" => crate::module_bindings::RpgAgentOperationType::UpdateDraftCard, + "sync_result_profile" => crate::module_bindings::RpgAgentOperationType::SyncResultProfile, + "generate_characters" => crate::module_bindings::RpgAgentOperationType::GenerateCharacters, + "generate_landmarks" => crate::module_bindings::RpgAgentOperationType::GenerateLandmarks, + "generate_role_assets" => crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets, + "sync_role_assets" => crate::module_bindings::RpgAgentOperationType::SyncRoleAssets, + "generate_scene_assets" => crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets, + "sync_scene_assets" => crate::module_bindings::RpgAgentOperationType::SyncSceneAssets, + "expand_long_tail" => crate::module_bindings::RpgAgentOperationType::ExpandLongTail, + "publish_world" => crate::module_bindings::RpgAgentOperationType::PublishWorld, + "revert_checkpoint" => crate::module_bindings::RpgAgentOperationType::RevertCheckpoint, + _ => crate::module_bindings::RpgAgentOperationType::ProcessMessage, + } +} + fn format_rpg_agent_operation_status( value: crate::module_bindings::RpgAgentOperationStatus, ) -> &'static str { @@ -5285,6 +5335,15 @@ fn format_rpg_agent_operation_status( } } +fn map_custom_world_agent_operation_status(value: &str) -> crate::module_bindings::RpgAgentOperationStatus { + match value.trim() { + "queued" => crate::module_bindings::RpgAgentOperationStatus::Queued, + "completed" => crate::module_bindings::RpgAgentOperationStatus::Completed, + "failed" => crate::module_bindings::RpgAgentOperationStatus::Failed, + _ => crate::module_bindings::RpgAgentOperationStatus::Running, + } +} + fn parse_rpg_agent_operation_status_record( value: &str, ) -> Result { @@ -5970,6 +6029,20 @@ 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, + 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/custom_world_agent_operation_progress_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_progress_input_type.rs new file mode 100644 index 00000000..45892831 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_agent_operation_progress_input_type.rs @@ -0,0 +1,32 @@ +// 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::rpg_agent_operation_status_type::RpgAgentOperationStatus; +use super::rpg_agent_operation_type_type::RpgAgentOperationType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldAgentOperationProgressInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub operation_type: RpgAgentOperationType, + pub operation_status: RpgAgentOperationStatus, + pub phase_label: String, + pub phase_detail: String, + pub operation_progress: u32, + pub error_message: Option::, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for CustomWorldAgentOperationProgressInput { + type Module = super::RemoteModule; +} 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 967cae67..c5b74014 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -44,13 +44,10 @@ pub mod asset_object_access_policy_type; pub mod asset_object_procedure_result_type; pub mod asset_object_upsert_input_type; pub mod asset_object_upsert_snapshot_type; -<<<<<<< HEAD -======= pub mod auth_store_snapshot_type; pub mod auth_store_snapshot_procedure_result_type; pub mod auth_store_snapshot_record_type; pub mod auth_store_snapshot_upsert_input_type; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod battle_mode_type; pub mod battle_state_type; pub mod battle_state_input_type; @@ -115,6 +112,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; @@ -346,10 +344,7 @@ pub mod ai_task_stage_table; pub mod ai_text_chunk_table; pub mod asset_entity_binding_table; pub mod asset_object_table; -<<<<<<< HEAD -======= pub mod auth_store_snapshot_table; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod battle_state_table; pub mod big_fish_agent_message_table; pub mod big_fish_asset_slot_table; @@ -407,17 +402,11 @@ pub mod delete_runtime_snapshot_and_return_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod execute_custom_world_agent_action_procedure; pub mod fail_ai_task_and_return_procedure; -<<<<<<< HEAD pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_puzzle_agent_message_turn_procedure; pub mod generate_big_fish_asset_procedure; -======= -pub mod finalize_custom_world_agent_message_turn_procedure; -pub mod finalize_puzzle_agent_message_turn_procedure; -pub mod generate_big_fish_asset_procedure; pub mod get_auth_store_snapshot_procedure; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub mod get_battle_state_procedure; pub mod get_big_fish_run_procedure; pub mod get_big_fish_session_procedure; @@ -471,6 +460,7 @@ pub mod swap_puzzle_pieces_procedure; 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_custom_world_agent_operation_progress_procedure; pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_custom_world_profile_and_return_procedure; pub mod upsert_npc_state_and_return_procedure; @@ -511,13 +501,10 @@ pub use asset_object_access_policy_type::AssetObjectAccessPolicy; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; pub use asset_object_upsert_input_type::AssetObjectUpsertInput; pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; -<<<<<<< HEAD -======= pub use auth_store_snapshot_type::AuthStoreSnapshot; pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord; pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use battle_mode_type::BattleMode; pub use battle_state_type::BattleState; pub use battle_state_input_type::BattleStateInput; @@ -582,6 +569,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; @@ -789,10 +777,7 @@ pub use ai_task_stage_table::*; pub use ai_text_chunk_table::*; pub use asset_entity_binding_table::*; pub use asset_object_table::*; -<<<<<<< HEAD -======= pub use auth_store_snapshot_table::*; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use battle_state_table::*; pub use big_fish_agent_message_table::*; pub use big_fish_asset_slot_table::*; @@ -874,17 +859,11 @@ pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_an pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; -<<<<<<< HEAD pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; -======= -pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; -pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; -pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot; ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) pub use get_battle_state_procedure::get_battle_state; pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_session_procedure::get_big_fish_session; @@ -938,6 +917,7 @@ pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; pub use update_puzzle_work_procedure::update_puzzle_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; +pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; 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; @@ -1249,10 +1229,7 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "ai_text_chunk" => db_update.ai_text_chunk.append(ai_text_chunk_table::parse_table_update(table_update)?), "asset_entity_binding" => db_update.asset_entity_binding.append(asset_entity_binding_table::parse_table_update(table_update)?), "asset_object" => db_update.asset_object.append(asset_object_table::parse_table_update(table_update)?), -<<<<<<< HEAD -======= "auth_store_snapshot" => db_update.auth_store_snapshot.append(auth_store_snapshot_table::parse_table_update(table_update)?), ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(battle_state_table::parse_table_update(table_update)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(big_fish_agent_message_table::parse_table_update(table_update)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(big_fish_asset_slot_table::parse_table_update(table_update)?), @@ -1313,10 +1290,7 @@ impl __sdk::DbUpdate for DbUpdate { diff.ai_text_chunk = cache.apply_diff_to_table::("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id); diff.asset_entity_binding = cache.apply_diff_to_table::("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id); diff.asset_object = cache.apply_diff_to_table::("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id); -<<<<<<< HEAD -======= diff.auth_store_snapshot = cache.apply_diff_to_table::("auth_store_snapshot", &self.auth_store_snapshot).with_updates_by_pk(|row| &row.snapshot_id); ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) diff.battle_state = cache.apply_diff_to_table::("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id); diff.big_fish_agent_message = cache.apply_diff_to_table::("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id); diff.big_fish_asset_slot = cache.apply_diff_to_table::("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id); @@ -1362,10 +1336,7 @@ for table_rows in raw.tables { "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), -<<<<<<< HEAD -======= "auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -1411,10 +1382,7 @@ for table_rows in raw.tables { "ai_text_chunk" => db_update.ai_text_chunk.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "asset_entity_binding" => db_update.asset_entity_binding.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "asset_object" => db_update.asset_object.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), -<<<<<<< HEAD -======= "auth_store_snapshot" => db_update.auth_store_snapshot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) "battle_state" => db_update.battle_state.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "big_fish_agent_message" => db_update.big_fish_agent_message.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "big_fish_asset_slot" => db_update.big_fish_asset_slot.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -1511,10 +1479,7 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { callbacks.invoke_table_row_callbacks::("ai_text_chunk", &self.ai_text_chunk, event); callbacks.invoke_table_row_callbacks::("asset_entity_binding", &self.asset_entity_binding, event); callbacks.invoke_table_row_callbacks::("asset_object", &self.asset_object, event); -<<<<<<< HEAD -======= callbacks.invoke_table_row_callbacks::("auth_store_snapshot", &self.auth_store_snapshot, event); ->>>>>>> 4f272a50 (迁移后端认证与拆分 Spacetime 客户端) callbacks.invoke_table_row_callbacks::("battle_state", &self.battle_state, event); callbacks.invoke_table_row_callbacks::("big_fish_agent_message", &self.big_fish_agent_message, event); callbacks.invoke_table_row_callbacks::("big_fish_asset_slot", &self.big_fish_asset_slot, event); diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs new file mode 100644 index 00000000..49029573 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_custom_world_agent_operation_progress_procedure.rs @@ -0,0 +1,64 @@ +// 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_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; +use super::custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertCustomWorldAgentOperationProgressArgs { + pub input: CustomWorldAgentOperationProgressInput, +} + +impl __sdk::InModule for UpsertCustomWorldAgentOperationProgressArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_custom_world_agent_operation_progress`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_custom_world_agent_operation_progress { + fn upsert_custom_world_agent_operation_progress( + &self, + input: CustomWorldAgentOperationProgressInput, + ) { + self.upsert_custom_world_agent_operation_progress_then(input, |_, _| {}); + } + + fn upsert_custom_world_agent_operation_progress_then( + &self, + input: CustomWorldAgentOperationProgressInput, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_custom_world_agent_operation_progress for super::RemoteProcedures { + fn upsert_custom_world_agent_operation_progress_then( + &self, + input: CustomWorldAgentOperationProgressInput, + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>( + "upsert_custom_world_agent_operation_progress", + UpsertCustomWorldAgentOperationProgressArgs { input }, + __callback, + ); + } +} 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 3a5c084c..176c4155 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1588,13 +1588,15 @@ fn execute_custom_world_agent_action_tx( } "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), - "generate_characters" - | "generate_landmarks" - | "generate_role_assets" - | "sync_role_assets" - | "generate_scene_assets" - | "sync_scene_assets" - | "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), + "generate_characters" | "generate_landmarks" => { + execute_generate_entities_action(ctx, &session, &input, &payload) + } + "generate_role_assets" | "generate_scene_assets" => { + execute_prepare_asset_studio_action(ctx, &session, &input, &payload) + } + "sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload), + "sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload), + "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), other => Err(format!("custom world action `{other}` 当前尚未支持")), } } @@ -2134,6 +2136,146 @@ fn execute_revert_checkpoint_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } + +fn execute_prepare_asset_studio_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_draft_refining_stage(session.stage, input.action.as_str())?; + let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?; + let (focus_id, operation_type, message_text, phase_label, phase_detail) = + if input.action == "generate_role_assets" { + let role_id = read_first_payload_text(payload, "roleIds", "roleId") + .ok_or_else(|| "generate_role_assets requires roleIds".to_string())?; + let role = find_profile_entity_by_id(&draft_profile, &["playableNpcs", "storyNpcs"], &role_id) + .ok_or_else(|| "未找到目标角色,无法进入角色资产工坊。".to_string())?; + let role_name = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); + ( + role_id, + RpgAgentOperationType::GenerateRoleAssets, + format!("已为「{}」准备好角色资产工坊,先生成主图候选,再补核心动作。", role_name), + "角色资产工坊已就绪", + format!("「{}」现在可以开始生成主图和动作。", role_name), + ) + } else { + let scene_id = read_first_payload_text(payload, "sceneIds", "sceneId") + .ok_or_else(|| "generate_scene_assets requires sceneIds".to_string())?; + let scene_kind = payload.get("sceneKind").and_then(JsonValue::as_str).map(str::trim).unwrap_or("landmark"); + let scene = if scene_kind == "camp" { + draft_profile.get("camp").and_then(JsonValue::as_object) + } else { + find_profile_entity_by_id(&draft_profile, &["landmarks"], &scene_id) + } + .ok_or_else(|| "未找到目标场景,无法进入场景资产工坊。".to_string())?; + let scene_name = read_optional_text_field(scene, &["name"]) + .unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string()); + ( + scene_id, + RpgAgentOperationType::GenerateSceneAssets, + format!("已为「{}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。", scene_name), + "场景资产工坊已就绪", + format!("「{}」现在可以继续生成和确认正式场景图。", scene_name), + ) + }; + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + stage: Some(RpgAgentStage::VisualRefining), + focus_card_id: Some(Some(focus_id)), + last_assistant_reply: Some(Some(message_text.clone())), + 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, &message_text, input.submitted_at_micros); + let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, operation_type, phase_label, &phase_detail, input.submitted_at_micros); + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_sync_role_assets_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_draft_refining_stage(session.stage, "sync_role_assets")?; + let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "sync_role_assets requires an existing draft foundation".to_string())?; + let role_id = read_required_payload_text(payload, "roleId", "sync_role_assets requires roleId")?; + let portrait_path = read_required_payload_text(payload, "portraitPath", "sync_role_assets requires portraitPath")?; + let generated_visual_asset_id = read_required_payload_text(payload, "generatedVisualAssetId", "sync_role_assets requires generatedVisualAssetId")?; + let generated_animation_set_id = payload.get("generatedAnimationSetId").and_then(JsonValue::as_str).map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned); + let animation_map = payload.get("animationMap").cloned(); + let updated_role = apply_role_asset_publish_result(&mut draft_profile, &role_id, &portrait_path, &generated_visual_asset_id, generated_animation_set_id.as_deref(), animation_map)?; + let role_name = read_optional_text_field(&updated_role, &["name"]).unwrap_or_else(|| "当前角色".to_string()); + let asset_status = resolve_role_asset_status(&updated_role); + let asset_status_label = resolve_role_asset_status_label(asset_status).to_string(); + upsert_asset_role_card(ctx, &session.session_id, &role_id, &updated_role, asset_status, &asset_status_label, input.submitted_at_micros)?; + let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json)); + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + stage: Some(RpgAgentStage::VisualRefining), + focus_card_id: Some(Some(role_id.clone())), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + last_assistant_reply: Some(Some(format!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_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, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?), + checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-role-assets", &format!("同步角色资产 {}", role_name), session))?), + asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), + 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!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label), input.submitted_at_micros); + let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncRoleAssets, "角色资产已同步", &format!("「{}」的资产状态已更新为{}。", role_name, asset_status_label), input.submitted_at_micros); + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + +fn execute_sync_scene_assets_action( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, + input: &CustomWorldAgentActionExecuteInput, + payload: &JsonMap, +) -> Result { + ensure_draft_refining_stage(session.stage, "sync_scene_assets")?; + let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "sync_scene_assets requires an existing draft foundation".to_string())?; + let scene_id = read_required_payload_text(payload, "sceneId", "sync_scene_assets requires sceneId")?; + let scene_kind = read_required_payload_text(payload, "sceneKind", "sync_scene_assets requires sceneKind")?; + let image_src = read_required_payload_text(payload, "imageSrc", "sync_scene_assets requires imageSrc")?; + let generated_scene_asset_id = read_required_payload_text(payload, "generatedSceneAssetId", "sync_scene_assets requires generatedSceneAssetId")?; + let updated_scene = apply_scene_asset_publish_result(&mut draft_profile, &scene_id, &scene_kind, &image_src, &generated_scene_asset_id, payload.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null), payload.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null))?; + let scene_name = read_optional_text_field(&updated_scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "当前场景" }.to_string()); + upsert_asset_scene_card(ctx, &session.session_id, &scene_id, &scene_kind, &updated_scene, input.submitted_at_micros)?; + let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json)); + let next_session = rebuild_custom_world_agent_session_row( + session, + CustomWorldAgentSessionPatch { + stage: Some(RpgAgentStage::VisualRefining), + focus_card_id: Some(Some(scene_id.clone())), + draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)), + last_assistant_reply: Some(Some(format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name))), + 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, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?), + checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-scene-assets", &format!("同步场景资产 {}", scene_name), session))?), + asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?), + 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!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name), input.submitted_at_micros); + let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncSceneAssets, "场景资产已同步", &format!("「{}」的场景图已经进入当前草稿。", scene_name), input.submitted_at_micros); + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + + fn execute_placeholder_custom_world_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -3278,6 +3420,121 @@ fn parse_json_array_or_empty(raw: &str) -> Vec { .unwrap_or_default() } +fn read_first_payload_text(payload: &JsonMap, array_key: &str, scalar_key: &str) -> Option { + payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str) + .or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str)) + .map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned) +} + +fn find_profile_entity_by_id<'a>(profile: &'a JsonMap, fields: &[&str], entity_id: &str) -> Option<&'a JsonMap> { + for field in fields { + if let Some(entries) = profile.get(*field).and_then(JsonValue::as_array) { + for entry in entries { + let Some(object) = entry.as_object() else { continue; }; + if read_optional_text_field(object, &["id"]).as_deref() == Some(entity_id) { return Some(object); } + } + } + } + None +} + +fn apply_role_asset_publish_result(profile: &mut JsonMap, role_id: &str, portrait_path: &str, generated_visual_asset_id: &str, generated_animation_set_id: Option<&str>, animation_map: Option) -> Result, String> { + for field in ["playableNpcs", "storyNpcs"] { + let Some(entries) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; }; + for entry in entries { + let Some(object) = entry.as_object_mut() else { continue; }; + if read_optional_text_field(object, &["id"]).as_deref() != Some(role_id) { continue; } + object.insert("imageSrc".to_string(), JsonValue::String(portrait_path.to_string())); + object.insert("generatedVisualAssetId".to_string(), JsonValue::String(generated_visual_asset_id.to_string())); + if let Some(asset_id) = generated_animation_set_id { object.insert("generatedAnimationSetId".to_string(), JsonValue::String(asset_id.to_string())); } + if let Some(map) = animation_map { object.insert("animationMap".to_string(), map); } + return Ok(object.clone()); + } + } + Err("目标角色不存在,无法同步角色资产。".to_string()) +} + +fn apply_scene_asset_publish_result(profile: &mut JsonMap, scene_id: &str, scene_kind: &str, image_src: &str, generated_scene_asset_id: &str, generated_scene_prompt: JsonValue, generated_scene_model: JsonValue) -> Result, String> { + let updated_scene = if scene_kind == "camp" { + let camp = profile.get_mut("camp").and_then(JsonValue::as_object_mut).ok_or_else(|| "目标营地不存在,无法同步场景资产。".to_string())?; + if read_optional_text_field(camp, &["id"]).as_deref() != Some(scene_id) { return Err("目标营地不存在,无法同步场景资产。".to_string()); } + camp.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); + camp.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string())); + camp.insert("generatedScenePrompt".to_string(), generated_scene_prompt); + camp.insert("generatedSceneModel".to_string(), generated_scene_model); + camp.clone() + } else { + let landmarks = profile.get_mut("landmarks").and_then(JsonValue::as_array_mut).ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?; + let mut updated = None; + for entry in landmarks { + let Some(object) = entry.as_object_mut() else { continue; }; + if read_optional_text_field(object, &["id"]).as_deref() != Some(scene_id) { continue; } + object.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string())); + object.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string())); + object.insert("generatedScenePrompt".to_string(), generated_scene_prompt.clone()); + object.insert("generatedSceneModel".to_string(), generated_scene_model.clone()); + updated = Some(object.clone()); + break; + } + updated.ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())? + }; + update_scene_chapter_acts_for_scene(profile, scene_id, image_src, generated_scene_asset_id); + Ok(updated_scene) +} + +fn update_scene_chapter_acts_for_scene(profile: &mut JsonMap, scene_id: &str, image_src: &str, generated_scene_asset_id: &str) { + let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; }; + for chapter in chapters { + let Some(chapter_object) = chapter.as_object_mut() else { continue; }; + if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; } + let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; }; + for act in acts { + if let Some(act_object) = act.as_object_mut() { + act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string())); + act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string())); + } + } + } +} + +fn resolve_role_asset_status(role: &JsonMap) -> CustomWorldRoleAssetStatus { + let has_portrait = read_optional_text_field(role, &["imageSrc"]).is_some() && read_optional_text_field(role, &["generatedVisualAssetId"]).is_some(); + if !has_portrait { return CustomWorldRoleAssetStatus::Missing; } + let has_animation_set = read_optional_text_field(role, &["generatedAnimationSetId"]).is_some(); + let has_animation_map = role.get("animationMap").and_then(JsonValue::as_object).map(|map| !map.is_empty()).unwrap_or(false); + if has_animation_set && has_animation_map { CustomWorldRoleAssetStatus::Complete } else if has_animation_set { CustomWorldRoleAssetStatus::AnimationsReady } else { CustomWorldRoleAssetStatus::VisualReady } +} + +fn resolve_role_asset_status_label(status: CustomWorldRoleAssetStatus) -> &'static str { + match status { CustomWorldRoleAssetStatus::Complete => "动作已就绪", CustomWorldRoleAssetStatus::AnimationsReady => "动作补齐中", CustomWorldRoleAssetStatus::VisualReady => "主图已就绪", CustomWorldRoleAssetStatus::Missing => "待生成主图" } +} + +fn build_asset_coverage_json(profile: &JsonMap) -> Result { + serialize_json_value(&json!({"roleAssets": [], "sceneAssets": [], "allRoleAssetsReady": false, "allSceneAssetsReady": false, "profileId": read_optional_text_field(profile, &["id"])})) +} + +fn upsert_asset_role_card(ctx: &ReducerContext, session_id: &str, role_id: &str, role: &JsonMap, asset_status: CustomWorldRoleAssetStatus, asset_status_label: &str, updated_at_micros: i64) -> Result<(), String> { + let title = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string()); + let subtitle = read_optional_text_field(role, &["role", "relationToPlayer", "publicMask"]).unwrap_or_else(|| asset_status_label.to_string()); + let summary = read_optional_text_field(role, &["summary", "description", "publicMask"]).unwrap_or_else(|| "角色资产已写回草稿。".to_string()); + upsert_asset_card(ctx, session_id, role_id, RpgAgentDraftCardKind::Character, &title, &subtitle, &summary, Some(asset_status), Some(asset_status_label), updated_at_micros) +} + +fn upsert_asset_scene_card(ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap, updated_at_micros: i64) -> Result<(), String> { + let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark }; + let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string()); + let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]).unwrap_or_else(|| "场景资产已就绪".to_string()); + let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]).unwrap_or_else(|| "场景图已写回草稿。".to_string()); + upsert_asset_card(ctx, session_id, scene_id, kind, &title, &subtitle, &summary, None, Some("场景图已就绪"), updated_at_micros) +} + +fn upsert_asset_card(ctx: &ReducerContext, session_id: &str, card_id: &str, kind: RpgAgentDraftCardKind, title: &str, subtitle: &str, summary: &str, asset_status: Option, asset_status_label: Option<&str>, updated_at_micros: i64) -> Result<(), String> { + let detail_payload = json!({"id": card_id, "kind": kind.as_str(), "title": title, "sections": [{"id": "summary", "label": "摘要", "value": summary}], "linkedIds": [], "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": []}); + let next = CustomWorldDraftCard { card_id: card_id.to_string(), session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Draft, title: title.to_string(), subtitle: subtitle.to_string(), summary: summary.to_string(), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status, asset_status_label: asset_status_label.map(ToOwned::to_owned), detail_payload_json: Some(serialize_json_value(&detail_payload)?), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros) }; + if let Some(existing) = ctx.db.custom_world_draft_card().card_id().find(&card_id.to_string()).filter(|entry| entry.session_id == session_id) { replace_custom_world_draft_card(ctx, &existing, CustomWorldDraftCard { created_at: existing.created_at, ..next }); } else { ctx.db.custom_world_draft_card().insert(next); } + Ok(()) +} + fn serialize_json_value(value: &JsonValue) -> Result { serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 06bd1979..5601a118 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -1192,6 +1192,25 @@ pub fn get_custom_world_agent_operation( } } +#[spacetimedb::procedure] +pub fn upsert_custom_world_agent_operation_progress( + ctx: &mut ProcedureContext, + input: CustomWorldAgentOperationProgressInput, +) -> CustomWorldAgentOperationProcedureResult { + match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) { + Ok(operation) => CustomWorldAgentOperationProcedureResult { + ok: true, + operation: Some(operation), + error_message: None, + }, + Err(message) => CustomWorldAgentOperationProcedureResult { + ok: false, + operation: None, + error_message: Some(message), + }, + } +} + fn continue_story_tx( ctx: &ReducerContext, input: StoryContinueInput, @@ -1474,6 +1493,59 @@ fn get_custom_world_agent_operation_tx( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +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())?; + 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())?; + + let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let operation = if let Some(current) = ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&input.operation_id) + { + if current.session_id != input.session_id { + return Err("custom_world_agent_operation.session_id 不匹配".to_string()); + } + let next = rebuild_custom_world_agent_operation_row( + ¤t, + CustomWorldAgentOperationPatch { + status: Some(input.operation_status), + phase_label: Some(input.phase_label.clone()), + phase_detail: Some(input.phase_detail.clone()), + progress: Some(input.operation_progress), + error_message: Some(input.error_message.clone()), + updated_at_micros: Some(input.updated_at_micros), + }, + )?; + 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, + }) + }; + + Ok(build_custom_world_agent_operation_snapshot(&operation)) +} + fn finalize_custom_world_agent_message_turn_tx( ctx: &ReducerContext, input: CustomWorldAgentMessageFinalizeInput, @@ -2896,14 +2968,22 @@ fn execute_custom_world_agent_action_tx( .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "custom_world_agent_session 不存在".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 payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default(); @@ -2990,7 +3070,7 @@ fn execute_draft_foundation_action( updated_at, ); - let operation = build_and_insert_custom_world_operation( + let operation = complete_custom_world_operation( ctx, &input.operation_id, &session.session_id, @@ -2998,7 +3078,7 @@ fn execute_draft_foundation_action( "底稿已整理", "第一版 foundation draft 已写入会话与世界卡。", updated_at, - ); + )?; Ok(build_custom_world_agent_operation_snapshot(&operation)) } @@ -4117,6 +4197,53 @@ fn replace_custom_world_draft_card( ctx.db.custom_world_draft_card().insert(next); } +fn complete_custom_world_operation( + ctx: &ReducerContext, + operation_id: &str, + session_id: &str, + operation_type: RpgAgentOperationType, + phase_label: &str, + phase_detail: &str, + timestamp_micros: i64, +) -> Result { + if let Some(current) = ctx + .db + .custom_world_agent_operation() + .operation_id() + .find(&operation_id.to_string()) + { + if current.session_id != session_id { + return Err("custom_world_agent_operation.session_id 不匹配".to_string()); + } + if current.operation_type != operation_type { + return Err("custom_world_agent_operation.operation_type 不匹配".to_string()); + } + let next = rebuild_custom_world_agent_operation_row( + ¤t, + CustomWorldAgentOperationPatch { + status: Some(RpgAgentOperationStatus::Completed), + phase_label: Some(phase_label.to_string()), + phase_detail: Some(phase_detail.to_string()), + progress: Some(100), + error_message: Some(None), + updated_at_micros: Some(timestamp_micros), + }, + )?; + replace_custom_world_agent_operation(ctx, ¤t, next.clone()); + return Ok(next); + } + + Ok(build_and_insert_custom_world_operation( + ctx, + operation_id, + session_id, + operation_type, + phase_label, + phase_detail, + timestamp_micros, + )) +} + fn build_and_insert_custom_world_operation( ctx: &ReducerContext, operation_id: &str, diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 3657bf3b..87b700a0 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -112,3 +112,20 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to expect(screen.getAllByText('拼图').length).toBeGreaterThan(0); expect(screen.queryByText('我的拼图作品')).toBeNull(); }); + +test('creation hub shows delete action for persisted rpg drafts', () => { + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onDeletePublished={() => {}} + />, + ); + + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); +}); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index eb25a211..a6484527 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; @@ -199,9 +199,7 @@ export function CustomWorldCreationHub({ : null } onDelete={ - item.kind === 'rpg' && - item.item.status === 'published' && - item.item.profileId + item.kind === 'rpg' && item.item.profileId ? () => { onDeletePublished?.(item.item); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 80d291da..00c2a6f2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1216,6 +1216,42 @@ export function PlatformEntryFlowShellImpl({ ], ); + const handleDeleteLibraryEntry = useCallback( + (entry: CustomWorldLibraryEntry) => { + if (!entry.profileId || deletingCreationWorkId) { + return; + } + + runProtectedAction(() => { + const confirmed = window.confirm( + `确认删除作品《${entry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`, + ); + if (!confirmed) { + return; + } + + setDeletingCreationWorkId(entry.profileId); + platformBootstrap.setPlatformError(null); + + void deleteRpgEntryWorldProfile(entry.profileId) + .then(async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap.refreshCustomWorldWorks().catch(() => []); + await platformBootstrap.refreshPublishedGallery().catch(() => []); + }) + .catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }); + }, + [deletingCreationWorkId, platformBootstrap, runProtectedAction], + ); + const handleDeletePublishedWork = useCallback( (work: (typeof creationHubItems)[number]) => { if (!work.profileId || deletingCreationWorkId) { @@ -1556,6 +1592,10 @@ export function PlatformEntryFlowShellImpl({ detailNavigation.openLibraryDetail(entry); }); }} + onDeleteLibraryEntry={(entry) => { + handleDeleteLibraryEntry(entry); + }} + deletingLibraryEntryId={deletingCreationWorkId} onSearchPublicCode={(keyword) => { void handlePublicCodeSearch(keyword); }} diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b6b8db67..a253c87d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -76,6 +76,10 @@ export interface RpgEntryHomeViewProps { onOpenLibraryDetail: ( entry: CustomWorldLibraryEntry, ) => void; + onDeleteLibraryEntry?: ( + entry: CustomWorldLibraryEntry, + ) => void; + deletingLibraryEntryId?: string | null; onSearchPublicCode?: (keyword: string) => void | Promise; isSearchingPublicCode?: boolean; onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void; @@ -303,9 +307,13 @@ function WorldCard({ function CreationLibraryCard({ entry, onClick, + onDelete, + isDeleting = false, }: { entry: CustomWorldLibraryEntry; onClick: () => void; + onDelete?: () => void; + isDeleting?: boolean; }) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); @@ -343,6 +351,19 @@ function CreationLibraryCard({ /> ) : null}
+ {onDelete ? ( + + ) : null}
onOpenLibraryDetail(entry)} + onDelete={onDeleteLibraryEntry ? () => onDeleteLibraryEntry(entry) : undefined} + isDeleting={deletingLibraryEntryId === entry.profileId} /> ), )}