Resolve spacetime client binding merge conflicts

This commit is contained in:
2026-04-24 14:44:46 +08:00
parent 4f369617c7
commit f65177b147
26 changed files with 2172 additions and 1020 deletions

2
.idea/.name generated
View File

@@ -1 +1 @@
PreGameSelectionFlow.tsx mod.rs

View File

@@ -83,3 +83,31 @@ cargo check -p spacetime-module
``` ```
结果:通过。`spacetime-module` 仅保留仓库既有 glob re-export warning。 结果:通过。`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 软删除。

View File

@@ -52,3 +52,34 @@ cargo test -p api-server custom_world_foundation_draft --no-default-features
``` ```
结果:`3 passed` 结果:`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`

View File

@@ -26,26 +26,28 @@ Agent 结果页点击新增场景角色 / 新增场景
-> 刷新结果页 profile -> 刷新结果页 profile
``` ```
说明:当前可扮演角色 tab 的“新增可扮演角色”会调用 `generate_characters`,后端现阶段会追加到 `storyNpcs`。因此严格意义上的“新增可扮演角色”仍未完整迁移,需要后续给 action 增加角色类型参数或新增 `generate_playable_characters` 说明:当前可扮演角色 tab 的“新增可扮演角色”会调用 `generate_characters` 并传入 `roleType=playable`Rust 会写入 `draftProfile.playableNpcs`;场景角色则写入 `draftProfile.storyNpcs`
## 已迁移 / 可见 ## 已迁移 / 可见
1. 删除作品:已有 Rust + SpacetimeDB 软删除链路。 1. 删除作品:已有 Rust + SpacetimeDB 软删除链路。
2. 新增场景角色:结果页可见,调用 Rust `generate_characters` 2. 新增可扮演角色:结果页可见,调用 Rust `generate_characters(roleType=playable)` 并写入 `playableNpcs`
3. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks` 3. 新增场景角色:结果页可见,调用 Rust `generate_characters(roleType=story)` 并写入 `storyNpcs`
4. Agent 结果页发布进入世界:已有 `publish_world` + publish gate 链路 4. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks` 并写入 `landmarks`
5. 手动编辑结果页 profile目前仍通过 `sync_result_profile` 自动保存回 Agent session 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` 1. 单个角色 / 场景的细粒度编辑:前端 modal 仍编辑本地 profile再靠 `sync_result_profile` 同步SpacetimeDB 虽有 `update_draft_card`,但结果页表单尚未按 card section action 化
2. 批量删除场景角色:前端只改本地 profile再靠 `sync_result_profile` 同步,不是独立 Rust action 2. 长尾补全:`expand_long_tail` 本轮明确排除Rust 侧仍是 placeholder
3. 批量删除场景:前端只改本地 profile再靠 `sync_result_profile` 同步,不是独立 Rust action 3. 回滚 checkpointRust 有 `revert_checkpoint`,但结果页没有清晰可见入口
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. 回滚 checkpointRust 有 `revert_checkpoint`,但结果页没有清晰可见入口。
## 下一步建议 ## 下一步建议

View File

@@ -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、场景图请求的断言应保持不变。

View File

@@ -43,7 +43,12 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use crate::{ 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, state::AppState,
}; };
use tokio::time::sleep; use tokio::time::sleep;
@@ -1713,310 +1718,11 @@ fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAsset
} }
} }
fn build_character_animation_prompt( pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" ")
.chars()
.take(max_length)
.collect::<String>()
.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::<Vec<_>>()
.join("")
}
fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
BUILT_IN_MOTION_TEMPLATES BUILT_IN_MOTION_TEMPLATES
.iter() .iter()
.find(|template| template.id == id.trim()) .find(|template| template.id == id.trim())
} }
fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String { fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String {
let candidate = match payload.strategy { let candidate = match payload.strategy {
CharacterAnimationStrategy::ImageSequence => payload.image_sequence_model.as_str(), 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)) error.into_response_with_context(Some(request_context))
} }
struct MotionTemplate { pub(crate) struct MotionTemplate {
id: &'static str, pub(crate) id: &'static str,
label: &'static str, pub(crate) label: &'static str,
animation: &'static str, pub(crate) animation: &'static str,
prompt_suffix: &'static str, pub(crate) prompt_suffix: &'static str,
notes: &'static str, pub(crate) notes: &'static str,
} }
impl MotionTemplate { impl MotionTemplate {
@@ -3677,6 +3383,11 @@ mod tests {
} }
#[test] #[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() { fn resolve_character_animation_model_uses_strategy_specific_field() {
let payload = CharacterAnimationGenerateRequest { let payload = CharacterAnimationGenerateRequest {
character_id: "hero".to_string(), character_id: "hero".to_string(),

View File

@@ -32,7 +32,12 @@ use shared_contracts::assets::{
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use crate::{ 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, state::AppState,
}; };
use tokio::time::sleep; 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::<Vec<_>>()
.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<DashScopeSettings, AppError> { fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前。
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() { if base_url.is_empty() {
return Err( return Err(
@@ -752,7 +707,6 @@ fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, App
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
}) })
} }
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> { fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder() reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms)) .timeout(Duration::from_millis(settings.request_timeout_ms))

View File

@@ -29,10 +29,10 @@ use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{ use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError, 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 .await
.map_err(|message| { .map_err(|error| {
custom_world_error_response( custom_world_error_response(
&request_context, &request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ map_custom_world_client_error(error),
"provider": "custom-world-agent",
"message": message,
})),
) )
})?; })?;
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json) spawn_custom_world_draft_foundation_job(
.map_err(|error| { state.clone(),
let (status, message) = match error { session,
DraftFoundationPayloadError::SerializePayload(message) => { owner_user_id,
(StatusCode::BAD_REQUEST, message) operation_id,
} payload,
DraftFoundationPayloadError::InvalidPayloadShape => ( );
StatusCode::BAD_REQUEST, return Ok(json_success_body(
"action payload 必须是 object".to_string(), Some(&request_context),
), json!({
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => { "operation": map_custom_world_agent_operation_response(operation),
(StatusCode::BAD_GATEWAY, message) }),
} ));
};
custom_world_error_response(
&request_context,
AppError::from_status(status).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?
} else { } else {
let generation_result = let generation_result =
generate_custom_world_agent_entities(llm_client, &session, &payload) 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<String>,
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
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( fn map_custom_world_library_entry_response(
entry: CustomWorldLibraryEntryRecord, entry: CustomWorldLibraryEntryRecord,
) -> CustomWorldLibraryEntryResponse { ) -> CustomWorldLibraryEntryResponse {

View File

@@ -5,6 +5,14 @@ use module_custom_world::{
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json}; 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::{ use spacetime_client::{
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentSessionRecord, CustomWorldAgentSessionRecord,
@@ -42,7 +50,7 @@ pub(crate) struct CustomWorldAgentTurnResult {
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PromptUserInputSignal { pub(crate) enum PromptUserInputSignal {
Rich, Rich,
Normal, Normal,
Sparse, Sparse,
@@ -51,14 +59,14 @@ enum PromptUserInputSignal {
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PromptDriftRisk { pub(crate) enum PromptDriftRisk {
Low, Low,
Medium, Medium,
High, High,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PromptConversationMode { pub(crate) enum PromptConversationMode {
Bootstrap, Bootstrap,
Expand, Expand,
Compress, Compress,
@@ -69,18 +77,18 @@ enum PromptConversationMode {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
#[allow(dead_code)] #[allow(dead_code)]
struct PromptDynamicState { pub(crate) struct PromptDynamicState {
current_turn: u32, current_turn: u32,
progress_percent: u32, progress_percent: u32,
user_input_signal: PromptUserInputSignal, pub(crate) user_input_signal: PromptUserInputSignal,
drift_risk: PromptDriftRisk, pub(crate) drift_risk: PromptDriftRisk,
quick_fill_requested: bool, quick_fill_requested: bool,
conversation_mode: PromptConversationMode, pub(crate) conversation_mode: PromptConversationMode,
judgement_summary: String, pub(crate) judgement_summary: String,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct PromptDynamicStateInference { pub(crate) struct PromptDynamicStateInference {
user_input_signal: Option<PromptUserInputSignal>, user_input_signal: Option<PromptUserInputSignal>,
drift_risk: Option<PromptDriftRisk>, drift_risk: Option<PromptDriftRisk>,
conversation_mode: Option<PromptConversationMode>, conversation_mode: Option<PromptConversationMode>,
@@ -177,7 +185,7 @@ struct IconicElementValue {
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EightAnchorContent { pub(crate) struct EightAnchorContent {
#[serde(default)] #[serde(default)]
world_promise: Option<WorldPromiseValue>, world_promise: Option<WorldPromiseValue>,
#[serde(default)] #[serde(default)]
@@ -271,229 +279,6 @@ impl std::fmt::Display for CustomWorldTurnError {
impl std::error::Error 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. judgementSummary1 到 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 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 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<F>( pub(crate) async fn run_custom_world_agent_turn<F>(
request: CustomWorldAgentTurnRequest<'_>, request: CustomWorldAgentTurnRequest<'_>,
on_reply_update: F, 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<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
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<PromptUserInputSignal> {
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<PromptDriftRisk> {
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<PromptConversationMode> {
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 { fn latest_user_text(chat_history: &[JsonValue]) -> String {
chat_history chat_history
.iter() .iter()
@@ -2075,7 +1573,7 @@ fn serialize_json(value: &JsonValue, fallback: &str) -> String {
} }
impl PromptUserInputSignal { impl PromptUserInputSignal {
fn as_str(self) -> &'static str { pub(crate) fn as_str(self) -> &'static str {
match self { match self {
Self::Rich => "rich", Self::Rich => "rich",
Self::Normal => "normal", Self::Normal => "normal",
@@ -2087,7 +1585,7 @@ impl PromptUserInputSignal {
} }
impl PromptDriftRisk { impl PromptDriftRisk {
fn as_str(self) -> &'static str { pub(crate) fn as_str(self) -> &'static str {
match self { match self {
Self::Low => "low", Self::Low => "low",
Self::Medium => "medium", Self::Medium => "medium",
@@ -2097,7 +1595,7 @@ impl PromptDriftRisk {
} }
impl PromptConversationMode { impl PromptConversationMode {
fn as_str(self) -> &'static str { pub(crate) fn as_str(self) -> &'static str {
match self { match self {
Self::Bootstrap => "bootstrap", Self::Bootstrap => "bootstrap",
Self::Expand => "expand", Self::Expand => "expand",
@@ -2111,7 +1609,7 @@ impl PromptConversationMode {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::extract_reply_text_from_partial_json; use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json;
#[test] #[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() { fn extract_reply_text_from_partial_json_preserves_chinese_characters() {

View File

@@ -27,8 +27,15 @@ use tokio::time::sleep;
use webp::Encoder as WebpEncoder; use webp::Encoder as WebpEncoder;
use crate::{ use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, api_response::json_success_body,
request_context::RequestContext, state::AppState, 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)] #[derive(Clone, Debug, Deserialize)]
@@ -883,18 +890,8 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind:
return fallback; return fallback;
}; };
let request = LlmTextRequest::new(vec![ let request = LlmTextRequest::new(vec![
LlmMessage::system( LlmMessage::system(build_result_entity_system_prompt()),
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。", LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)),
),
LlmMessage::user(
json!({
"task": "generate_custom_world_entity",
"kind": kind,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]); ]);
llm_client llm_client
@@ -915,18 +912,12 @@ async fn generate_scene_npc_with_fallback(
return fallback; return fallback;
}; };
let request = LlmTextRequest::new(vec![ let request = LlmTextRequest::new(vec![
LlmMessage::system( LlmMessage::system(build_result_scene_npc_system_prompt()),
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。", LlmMessage::user(build_result_scene_npc_user_prompt(
), profile,
LlmMessage::user( landmark_id,
json!({ &fallback,
"task": "generate_custom_world_scene_npc", )),
"landmarkId": landmark_id,
"profile": profile,
"fallback": fallback,
})
.to_string(),
),
]); ]);
llm_client llm_client

View File

@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" ")
.chars()
.take(max_length)
.collect::<String>()
.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::<Vec<_>>()
.join("")
}

View File

@@ -15,11 +15,25 @@ pub enum DraftFoundationPayloadError {
InvalidGeneratedDraft(String), 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( pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient, llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord, session: &CustomWorldAgentSessionRecord,
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
) -> Result<CustomWorldFoundationDraftResult, String> { ) -> Result<CustomWorldFoundationDraftResult, String> {
let setting_text = build_foundation_generation_seed_text(session); let setting_text = build_foundation_generation_seed_text(session);
emit_foundation_draft_progress(
&mut on_progress,
"整理世界骨架",
"正在根据创作者锚点生成第一版世界框架。",
12,
);
let mut framework = request_foundation_json_stage( let mut framework = request_foundation_json_stage(
llm_client, llm_client,
build_custom_world_framework_prompt(setting_text.as_str()), build_custom_world_framework_prompt(setting_text.as_str()),
@@ -36,6 +50,8 @@ pub async fn generate_custom_world_foundation_draft(
&framework, &framework,
"playable", "playable",
FOUNDATION_DRAFT_PLAYABLE_COUNT, FOUNDATION_DRAFT_PLAYABLE_COUNT,
(16, 30),
&mut on_progress,
) )
.await?; .await?;
framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone()); framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone());
@@ -45,6 +61,8 @@ pub async fn generate_custom_world_foundation_draft(
&framework, &framework,
"story", "story",
FOUNDATION_DRAFT_STORY_COUNT, FOUNDATION_DRAFT_STORY_COUNT,
(30, 44),
&mut on_progress,
) )
.await?; .await?;
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone()); framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
@@ -53,6 +71,8 @@ pub async fn generate_custom_world_foundation_draft(
llm_client, llm_client,
&framework, &framework,
FOUNDATION_DRAFT_LANDMARK_COUNT, FOUNDATION_DRAFT_LANDMARK_COUNT,
(44, 56),
&mut on_progress,
) )
.await?; .await?;
framework["landmarks"] = JsonValue::Array(landmark_seeds.clone()); framework["landmarks"] = JsonValue::Array(landmark_seeds.clone());
@@ -62,6 +82,8 @@ pub async fn generate_custom_world_foundation_draft(
&framework, &framework,
&story_outlines, &story_outlines,
&landmark_seeds, &landmark_seeds,
(56, 66),
&mut on_progress,
) )
.await?; .await?;
framework["landmarks"] = JsonValue::Array(landmarks.clone()); framework["landmarks"] = JsonValue::Array(landmarks.clone());
@@ -72,6 +94,8 @@ pub async fn generate_custom_world_foundation_draft(
"playable", "playable",
&playable_outlines, &playable_outlines,
"narrative", "narrative",
(66, 76),
&mut on_progress,
) )
.await?; .await?;
let playable_detailed = expand_foundation_role_entries( let playable_detailed = expand_foundation_role_entries(
@@ -80,6 +104,8 @@ pub async fn generate_custom_world_foundation_draft(
"playable", "playable",
&playable_narrative, &playable_narrative,
"dossier", "dossier",
(76, 84),
&mut on_progress,
) )
.await?; .await?;
let story_narrative = expand_foundation_role_entries( let story_narrative = expand_foundation_role_entries(
@@ -88,6 +114,8 @@ pub async fn generate_custom_world_foundation_draft(
"story", "story",
&story_outlines, &story_outlines,
"narrative", "narrative",
(84, 92),
&mut on_progress,
) )
.await?; .await?;
let story_detailed = expand_foundation_role_entries( let story_detailed = expand_foundation_role_entries(
@@ -96,9 +124,18 @@ pub async fn generate_custom_world_foundation_draft(
"story", "story",
&story_narrative, &story_narrative,
"dossier", "dossier",
(92, 96),
&mut on_progress,
) )
.await?; .await?;
emit_foundation_draft_progress(
&mut on_progress,
"编译世界底稿",
"正在把分批生成结果直接整理成第一版 foundation draft并同步兼容结果快照。",
97,
);
let draft_profile = build_foundation_draft_profile_from_framework( let draft_profile = build_foundation_draft_profile_from_framework(
framework, framework,
playable_detailed, playable_detailed,
@@ -166,6 +203,8 @@ async fn generate_foundation_role_outline_entries(
framework: &JsonValue, framework: &JsonValue,
role_type: &str, role_type: &str,
total_count: usize, total_count: usize,
progress_range: (u32, u32),
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> { ) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new(); let mut merged_entries = Vec::new();
let planned_batch_count = total_count let planned_batch_count = total_count
@@ -178,6 +217,24 @@ async fn generate_foundation_role_outline_entries(
let batch_count = let batch_count =
(total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE); (total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE);
let forbidden_names = names_from_entries(&merged_entries); 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( let raw = request_foundation_json_stage(
llm_client, llm_client,
build_custom_world_role_outline_batch_prompt( build_custom_world_role_outline_batch_prompt(
@@ -210,13 +267,27 @@ async fn generate_foundation_role_outline_entries(
let key = role_key(role_type); let key = role_key(role_type);
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count)); 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<JsonValue> = 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( async fn generate_foundation_landmark_seed_entries(
llm_client: &LlmClient, llm_client: &LlmClient,
framework: &JsonValue, framework: &JsonValue,
total_count: usize, total_count: usize,
progress_range: (u32, u32),
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> { ) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new(); let mut merged_entries = Vec::new();
let planned_batch_count = total_count.div_ceil(FOUNDATION_LANDMARK_BATCH_SIZE).max(1); 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 batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
let forbidden_names = names_from_entries(&merged_entries); 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( let raw = request_foundation_json_stage(
llm_client, llm_client,
build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names), build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names),
@@ -247,7 +331,14 @@ async fn generate_foundation_landmark_seed_entries(
.await?; .await?;
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count)); 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<JsonValue> = 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( async fn expand_foundation_landmark_network_entries(
@@ -255,12 +346,28 @@ async fn expand_foundation_landmark_network_entries(
framework: &JsonValue, framework: &JsonValue,
story_npcs: &[JsonValue], story_npcs: &[JsonValue],
base_entries: &[JsonValue], base_entries: &[JsonValue],
progress_range: (u32, u32),
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> { ) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new(); let mut merged_entries = Vec::new();
for (batch_index, batch) in base_entries let batches: Vec<&[JsonValue]> = base_entries
.chunks(FOUNDATION_LANDMARK_BATCH_SIZE) .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( let raw = request_foundation_json_stage(
llm_client, llm_client,
build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch), build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch),
@@ -284,7 +391,16 @@ async fn expand_foundation_landmark_network_entries(
) )
.await?; .await?;
merged_entries.extend(array_field(&raw, "landmarks")); 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)) Ok(merge_entries_by_name(base_entries, &merged_entries))
} }
@@ -294,13 +410,39 @@ async fn expand_foundation_role_entries(
role_type: &str, role_type: &str,
base_entries: &[JsonValue], base_entries: &[JsonValue],
stage: &str, stage: &str,
progress_range: (u32, u32),
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
) -> Result<Vec<JsonValue>, String> { ) -> Result<Vec<JsonValue>, String> {
let mut merged_entries = Vec::new(); 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) .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 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( let raw = request_foundation_json_stage(
llm_client, llm_client,
build_custom_world_role_batch_prompt(framework, role_type, batch, stage), build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
@@ -326,9 +468,51 @@ async fn expand_foundation_role_entries(
) )
.await?; .await?;
merged_entries.extend(array_field(&raw, role_key(role_type))); 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)) 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。 // foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
pub fn build_draft_foundation_action_payload_json( pub fn build_draft_foundation_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest, payload: &ExecuteCustomWorldAgentActionRequest,
@@ -1528,7 +1712,7 @@ mod tests {
let llm_client = build_test_llm_client(server_url); let llm_client = build_test_llm_client(server_url);
let session = build_test_session(); 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 .await
.expect("draft generation should succeed"); .expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json) let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)

View File

@@ -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()
}

View File

@@ -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. judgementSummary1 到 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 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 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<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
pub(crate) fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
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<PromptUserInputSignal> {
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<PromptDriftRisk> {
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<PromptConversationMode> {
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 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}

View File

@@ -19,7 +19,10 @@ mod custom_world;
mod custom_world_agent_entities; mod custom_world_agent_entities;
mod custom_world_agent_turn; mod custom_world_agent_turn;
mod custom_world_ai; mod custom_world_ai;
mod custom_world_asset_prompts;
mod custom_world_foundation_draft; mod custom_world_foundation_draft;
mod custom_world_result_prompts;
mod custom_world_rpg_draft_prompts;
mod error_middleware; mod error_middleware;
mod health; mod health;
mod http_error; mod http_error;

View File

@@ -562,6 +562,21 @@ pub struct CustomWorldAgentOperationGetInput {
pub operation_id: String, 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<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentOperationProcedureResult { pub struct CustomWorldAgentOperationProcedureResult {
@@ -1205,6 +1220,24 @@ pub fn validate_custom_world_agent_operation_get_input(
Ok(()) 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( pub fn validate_custom_world_works_list_input(
input: &CustomWorldWorksListInput, input: &CustomWorldWorksListInput,
) -> Result<(), CustomWorldFieldError> { ) -> Result<(), CustomWorldFieldError> {

View File

@@ -187,6 +187,7 @@ use crate::module_bindings::{
CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput, CustomWorldAgentMessageSubmitInput as BindingCustomWorldAgentMessageSubmitInput,
CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput, CustomWorldAgentOperationGetInput as BindingCustomWorldAgentOperationGetInput,
CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult, CustomWorldAgentOperationProcedureResult as BindingCustomWorldAgentOperationProcedureResult,
CustomWorldAgentOperationProgressInput as BindingCustomWorldAgentOperationProgressInput,
CustomWorldAgentOperationSnapshot as BindingCustomWorldAgentOperationSnapshot, CustomWorldAgentOperationSnapshot as BindingCustomWorldAgentOperationSnapshot,
CustomWorldAgentSessionCreateInput as BindingCustomWorldAgentSessionCreateInput, CustomWorldAgentSessionCreateInput as BindingCustomWorldAgentSessionCreateInput,
CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput, CustomWorldAgentSessionGetInput as BindingCustomWorldAgentSessionGetInput,
@@ -373,6 +374,7 @@ use crate::module_bindings::{
swap_puzzle_pieces_procedure::swap_puzzle_pieces as _, swap_puzzle_pieces_procedure::swap_puzzle_pieces as _,
unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return as _, unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return as _,
update_puzzle_work_procedure::update_puzzle_work 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_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_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 _, upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return as _,
@@ -1886,6 +1888,36 @@ impl SpacetimeClient {
.await .await
} }
pub async fn upsert_custom_world_agent_operation_progress(
&self,
input: CustomWorldAgentOperationProgressRecordInput,
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
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( pub async fn list_platform_browse_history(
&self, &self,
user_id: String, 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( fn format_rpg_agent_operation_status(
value: crate::module_bindings::RpgAgentOperationStatus, value: crate::module_bindings::RpgAgentOperationStatus,
) -> &'static str { ) -> &'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( fn parse_rpg_agent_operation_status_record(
value: &str, value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationStatus, SpacetimeClientError> { ) -> Result<crate::module_bindings::RpgAgentOperationStatus, SpacetimeClientError> {
@@ -5970,6 +6029,20 @@ pub struct CustomWorldAgentOperationRecord {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentOperationProgressRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub operation_type: String,
pub operation_status: String,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldDraftCardRecord { pub struct CustomWorldDraftCardRecord {
pub card_id: String, pub card_id: String,

View File

@@ -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::<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for CustomWorldAgentOperationProgressInput {
type Module = super::RemoteModule;
}

View File

@@ -44,13 +44,10 @@ pub mod asset_object_access_policy_type;
pub mod asset_object_procedure_result_type; pub mod asset_object_procedure_result_type;
pub mod asset_object_upsert_input_type; pub mod asset_object_upsert_input_type;
pub mod asset_object_upsert_snapshot_type; pub mod asset_object_upsert_snapshot_type;
<<<<<<< HEAD
=======
pub mod auth_store_snapshot_type; pub mod auth_store_snapshot_type;
pub mod auth_store_snapshot_procedure_result_type; pub mod auth_store_snapshot_procedure_result_type;
pub mod auth_store_snapshot_record_type; pub mod auth_store_snapshot_record_type;
pub mod auth_store_snapshot_upsert_input_type; pub mod auth_store_snapshot_upsert_input_type;
>>>>>>> 4f272a50 ( Spacetime )
pub mod battle_mode_type; pub mod battle_mode_type;
pub mod battle_state_type; pub mod battle_state_type;
pub mod battle_state_input_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_type;
pub mod custom_world_agent_operation_get_input_type; pub mod custom_world_agent_operation_get_input_type;
pub mod custom_world_agent_operation_procedure_result_type; pub mod custom_world_agent_operation_procedure_result_type;
pub mod custom_world_agent_operation_progress_input_type;
pub mod custom_world_agent_operation_snapshot_type; pub mod custom_world_agent_operation_snapshot_type;
pub mod custom_world_agent_session_type; pub mod custom_world_agent_session_type;
pub mod custom_world_agent_session_create_input_type; pub mod custom_world_agent_session_create_input_type;
@@ -346,10 +344,7 @@ pub mod ai_task_stage_table;
pub mod ai_text_chunk_table; pub mod ai_text_chunk_table;
pub mod asset_entity_binding_table; pub mod asset_entity_binding_table;
pub mod asset_object_table; pub mod asset_object_table;
<<<<<<< HEAD
=======
pub mod auth_store_snapshot_table; pub mod auth_store_snapshot_table;
>>>>>>> 4f272a50 ( Spacetime )
pub mod battle_state_table; pub mod battle_state_table;
pub mod big_fish_agent_message_table; pub mod big_fish_agent_message_table;
pub mod big_fish_asset_slot_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 drag_puzzle_piece_or_group_procedure;
pub mod execute_custom_world_agent_action_procedure; pub mod execute_custom_world_agent_action_procedure;
pub mod fail_ai_task_and_return_procedure; pub mod fail_ai_task_and_return_procedure;
<<<<<<< HEAD
pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_big_fish_agent_message_turn_procedure;
pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure;
pub mod finalize_puzzle_agent_message_turn_procedure; pub mod finalize_puzzle_agent_message_turn_procedure;
pub mod generate_big_fish_asset_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; pub mod get_auth_store_snapshot_procedure;
>>>>>>> 4f272a50 ( Spacetime )
pub mod get_battle_state_procedure; pub mod get_battle_state_procedure;
pub mod get_big_fish_run_procedure; pub mod get_big_fish_run_procedure;
pub mod get_big_fish_session_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 unpublish_custom_world_profile_and_return_procedure;
pub mod update_puzzle_work_procedure; pub mod update_puzzle_work_procedure;
pub mod upsert_auth_store_snapshot_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_chapter_progression_and_return_procedure;
pub mod upsert_custom_world_profile_and_return_procedure; pub mod upsert_custom_world_profile_and_return_procedure;
pub mod upsert_npc_state_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_procedure_result_type::AssetObjectProcedureResult;
pub use asset_object_upsert_input_type::AssetObjectUpsertInput; pub use asset_object_upsert_input_type::AssetObjectUpsertInput;
pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
<<<<<<< HEAD
=======
pub use auth_store_snapshot_type::AuthStoreSnapshot; pub use auth_store_snapshot_type::AuthStoreSnapshot;
pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult; pub use auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult;
pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord; pub use auth_store_snapshot_record_type::AuthStoreSnapshotRecord;
pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
>>>>>>> 4f272a50 ( Spacetime )
pub use battle_mode_type::BattleMode; pub use battle_mode_type::BattleMode;
pub use battle_state_type::BattleState; pub use battle_state_type::BattleState;
pub use battle_state_input_type::BattleStateInput; 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_type::CustomWorldAgentOperation;
pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput;
pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult;
pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot;
pub use custom_world_agent_session_type::CustomWorldAgentSession; pub use custom_world_agent_session_type::CustomWorldAgentSession;
pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput; pub use custom_world_agent_session_create_input_type::CustomWorldAgentSessionCreateInput;
@@ -789,10 +777,7 @@ pub use ai_task_stage_table::*;
pub use ai_text_chunk_table::*; pub use ai_text_chunk_table::*;
pub use asset_entity_binding_table::*; pub use asset_entity_binding_table::*;
pub use asset_object_table::*; pub use asset_object_table::*;
<<<<<<< HEAD
=======
pub use auth_store_snapshot_table::*; pub use auth_store_snapshot_table::*;
>>>>>>> 4f272a50 ( Spacetime )
pub use battle_state_table::*; pub use battle_state_table::*;
pub use big_fish_agent_message_table::*; pub use big_fish_agent_message_table::*;
pub use big_fish_asset_slot_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 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 execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; 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_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_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 finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset; 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; 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_battle_state_procedure::get_battle_state;
pub use get_big_fish_run_procedure::get_big_fish_run; pub use get_big_fish_run_procedure::get_big_fish_run;
pub use get_big_fish_session_procedure::get_big_fish_session; 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 unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return;
pub use update_puzzle_work_procedure::update_puzzle_work; pub use update_puzzle_work_procedure::update_puzzle_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; 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_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_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return; pub use upsert_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)?), "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_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)?), "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)?), "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)?), "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_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)?), "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::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk).with_updates_by_pk(|row| &row.text_chunk_row_id); diff.ai_text_chunk = cache.apply_diff_to_table::<AiTextChunk>("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::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id); diff.asset_entity_binding = cache.apply_diff_to_table::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding).with_updates_by_pk(|row| &row.binding_id);
diff.asset_object = cache.apply_diff_to_table::<AssetObject>("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id); diff.asset_object = cache.apply_diff_to_table::<AssetObject>("asset_object", &self.asset_object).with_updates_by_pk(|row| &row.asset_object_id);
<<<<<<< HEAD
=======
diff.auth_store_snapshot = cache.apply_diff_to_table::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot).with_updates_by_pk(|row| &row.snapshot_id); diff.auth_store_snapshot = cache.apply_diff_to_table::<AuthStoreSnapshot>("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::<BattleState>("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id); diff.battle_state = cache.apply_diff_to_table::<BattleState>("battle_state", &self.battle_state).with_updates_by_pk(|row| &row.battle_state_id);
diff.big_fish_agent_message = cache.apply_diff_to_table::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message).with_updates_by_pk(|row| &row.message_id); diff.big_fish_agent_message = cache.apply_diff_to_table::<BigFishAgentMessage>("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::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot).with_updates_by_pk(|row| &row.slot_id); diff.big_fish_asset_slot = cache.apply_diff_to_table::<BigFishAssetSlot>("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)?), "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_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)?), "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)?), "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)?), "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_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)?), "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)?), "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_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)?), "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)?), "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)?), "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_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)?), "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::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk, event); callbacks.invoke_table_row_callbacks::<AiTextChunk>("ai_text_chunk", &self.ai_text_chunk, event);
callbacks.invoke_table_row_callbacks::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding, event); callbacks.invoke_table_row_callbacks::<AssetEntityBinding>("asset_entity_binding", &self.asset_entity_binding, event);
callbacks.invoke_table_row_callbacks::<AssetObject>("asset_object", &self.asset_object, event); callbacks.invoke_table_row_callbacks::<AssetObject>("asset_object", &self.asset_object, event);
<<<<<<< HEAD
=======
callbacks.invoke_table_row_callbacks::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot, event); callbacks.invoke_table_row_callbacks::<AuthStoreSnapshot>("auth_store_snapshot", &self.auth_store_snapshot, event);
>>>>>>> 4f272a50 ( Spacetime )
callbacks.invoke_table_row_callbacks::<BattleState>("battle_state", &self.battle_state, event); callbacks.invoke_table_row_callbacks::<BattleState>("battle_state", &self.battle_state, event);
callbacks.invoke_table_row_callbacks::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message, event); callbacks.invoke_table_row_callbacks::<BigFishAgentMessage>("big_fish_agent_message", &self.big_fish_agent_message, event);
callbacks.invoke_table_row_callbacks::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot, event); callbacks.invoke_table_row_callbacks::<BigFishAssetSlot>("big_fish_asset_slot", &self.big_fish_asset_slot, event);

View File

@@ -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<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
) + 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<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>(
"upsert_custom_world_agent_operation_progress",
UpsertCustomWorldAgentOperationProgressArgs { input },
__callback,
);
}
}

View File

@@ -1588,13 +1588,15 @@ fn execute_custom_world_agent_action_tx(
} }
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload), "publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload), "revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
"generate_characters" "generate_characters" | "generate_landmarks" => {
| "generate_landmarks" execute_generate_entities_action(ctx, &session, &input, &payload)
| "generate_role_assets" }
| "sync_role_assets" "generate_role_assets" | "generate_scene_assets" => {
| "generate_scene_assets" execute_prepare_asset_studio_action(ctx, &session, &input, &payload)
| "sync_scene_assets" }
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input), "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}` 当前尚未支持")), other => Err(format!("custom world action `{other}` 当前尚未支持")),
} }
} }
@@ -2134,6 +2136,146 @@ fn execute_revert_checkpoint_action(
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn execute_prepare_asset_studio_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
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<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
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<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
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( fn execute_placeholder_custom_world_action(
ctx: &ReducerContext, ctx: &ReducerContext,
session: &CustomWorldAgentSession, session: &CustomWorldAgentSession,
@@ -3278,6 +3420,121 @@ fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
.unwrap_or_default() .unwrap_or_default()
} }
fn read_first_payload_text(payload: &JsonMap<String, JsonValue>, array_key: &str, scalar_key: &str) -> Option<String> {
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<String, JsonValue>, fields: &[&str], entity_id: &str) -> Option<&'a JsonMap<String, JsonValue>> {
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<String, JsonValue>, role_id: &str, portrait_path: &str, generated_visual_asset_id: &str, generated_animation_set_id: Option<&str>, animation_map: Option<JsonValue>) -> Result<JsonMap<String, JsonValue>, 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<String, JsonValue>, scene_id: &str, scene_kind: &str, image_src: &str, generated_scene_asset_id: &str, generated_scene_prompt: JsonValue, generated_scene_model: JsonValue) -> Result<JsonMap<String, JsonValue>, 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<String, JsonValue>, 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<String, JsonValue>) -> 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<String, JsonValue>) -> Result<String, String> {
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<String, JsonValue>, 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<String, JsonValue>, 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<CustomWorldRoleAssetStatus>, 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<String, String> { fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}")) serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
} }

View File

@@ -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( fn continue_story_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: StoryContinueInput, input: StoryContinueInput,
@@ -1474,6 +1493,59 @@ fn get_custom_world_agent_operation_tx(
Ok(build_custom_world_agent_operation_snapshot(&operation)) Ok(build_custom_world_agent_operation_snapshot(&operation))
} }
fn upsert_custom_world_agent_operation_progress_tx(
ctx: &ReducerContext,
input: CustomWorldAgentOperationProgressInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
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(
&current,
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, &current, 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( fn finalize_custom_world_agent_message_turn_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: CustomWorldAgentMessageFinalizeInput, input: CustomWorldAgentMessageFinalizeInput,
@@ -2896,14 +2968,22 @@ fn execute_custom_world_agent_action_tx(
.filter(|row| row.owner_user_id == input.owner_user_id) .filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?; .ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if ctx if let Some(existing_operation) = ctx
.db .db
.custom_world_agent_operation() .custom_world_agent_operation()
.operation_id() .operation_id()
.find(&input.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(); 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, updated_at,
); );
let operation = build_and_insert_custom_world_operation( let operation = complete_custom_world_operation(
ctx, ctx,
&input.operation_id, &input.operation_id,
&session.session_id, &session.session_id,
@@ -2998,7 +3078,7 @@ fn execute_draft_foundation_action(
"底稿已整理", "底稿已整理",
"第一版 foundation draft 已写入会话与世界卡。", "第一版 foundation draft 已写入会话与世界卡。",
updated_at, updated_at,
); )?;
Ok(build_custom_world_agent_operation_snapshot(&operation)) 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); 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<CustomWorldAgentOperation, String> {
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(
&current,
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, &current, 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( fn build_and_insert_custom_world_operation(
ctx: &ReducerContext, ctx: &ReducerContext,
operation_id: &str, operation_id: &str,

View File

@@ -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.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull(); expect(screen.queryByText('我的拼图作品')).toBeNull();
}); });
test('creation hub shows delete action for persisted rpg drafts', () => {
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});

View File

@@ -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 { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
@@ -199,9 +199,7 @@ export function CustomWorldCreationHub({
: null : null
} }
onDelete={ onDelete={
item.kind === 'rpg' && item.kind === 'rpg' && item.item.profileId
item.item.status === 'published' &&
item.item.profileId
? () => { ? () => {
onDeletePublished?.(item.item); onDeletePublished?.(item.item);
} }

View File

@@ -1216,6 +1216,42 @@ export function PlatformEntryFlowShellImpl({
], ],
); );
const handleDeleteLibraryEntry = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
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( const handleDeletePublishedWork = useCallback(
(work: (typeof creationHubItems)[number]) => { (work: (typeof creationHubItems)[number]) => {
if (!work.profileId || deletingCreationWorkId) { if (!work.profileId || deletingCreationWorkId) {
@@ -1556,6 +1592,10 @@ export function PlatformEntryFlowShellImpl({
detailNavigation.openLibraryDetail(entry); detailNavigation.openLibraryDetail(entry);
}); });
}} }}
onDeleteLibraryEntry={(entry) => {
handleDeleteLibraryEntry(entry);
}}
deletingLibraryEntryId={deletingCreationWorkId}
onSearchPublicCode={(keyword) => { onSearchPublicCode={(keyword) => {
void handlePublicCodeSearch(keyword); void handlePublicCodeSearch(keyword);
}} }}

View File

@@ -76,6 +76,10 @@ export interface RpgEntryHomeViewProps {
onOpenLibraryDetail: ( onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>, entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void; ) => void;
onDeleteLibraryEntry?: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
deletingLibraryEntryId?: string | null;
onSearchPublicCode?: (keyword: string) => void | Promise<void>; onSearchPublicCode?: (keyword: string) => void | Promise<void>;
isSearchingPublicCode?: boolean; isSearchingPublicCode?: boolean;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void; onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
@@ -303,9 +307,13 @@ function WorldCard({
function CreationLibraryCard({ function CreationLibraryCard({
entry, entry,
onClick, onClick,
onDelete,
isDeleting = false,
}: { }: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>; entry: CustomWorldLibraryEntry<CustomWorldProfile>;
onClick: () => void; onClick: () => void;
onDelete?: () => void;
isDeleting?: boolean;
}) { }) {
const coverImage = resolvePlatformWorldCoverImage(entry); const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
@@ -343,6 +351,19 @@ function CreationLibraryCard({
/> />
) : null} ) : null}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" /> <div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={isDeleting}
className="platform-button platform-button--danger absolute right-2 top-2 z-20 min-h-0 rounded-full px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? '删除中' : '删除'}
</button>
) : null}
<div className="relative z-10 flex h-full min-w-0 flex-col"> <div className="relative z-10 flex h-full min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span <span
@@ -784,6 +805,8 @@ export function RpgEntryHomeView({
onOpenCreateTypePicker, onOpenCreateTypePicker,
onOpenGalleryDetail, onOpenGalleryDetail,
onOpenLibraryDetail, onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
onSearchPublicCode, onSearchPublicCode,
isSearchingPublicCode = false, isSearchingPublicCode = false,
onOpenProfileDashboardCard, onOpenProfileDashboardCard,
@@ -983,6 +1006,8 @@ export function RpgEntryHomeView({
key={`${entry.ownerUserId}:${entry.profileId}:mine`} key={`${entry.ownerUserId}:${entry.profileId}:mine`}
entry={entry} entry={entry}
onClick={() => onOpenLibraryDetail(entry)} onClick={() => onOpenLibraryDetail(entry)}
onDelete={onDeleteLibraryEntry ? () => onDeleteLibraryEntry(entry) : undefined}
isDeleting={deletingLibraryEntryId === entry.profileId}
/> />
), ),
)} )}