use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str = "你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。"; const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT: &str = "你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldGeneratedEntitiesResult { pub payload_json: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum CustomWorldGeneratedEntitiesPayloadError { SerializePayload(String), InvalidPayloadShape, } pub async fn generate_custom_world_agent_entities( llm_client: &LlmClient, session: &CustomWorldAgentSessionRecord, payload: &ExecuteCustomWorldAgentActionRequest, ) -> Result { let action = payload.action.trim(); let draft_profile = session .draft_profile .as_object() .ok_or_else(|| format!("{action} requires an existing draft foundation"))?; let count = ensure_count(payload.count); let prompt_seed = payload .prompt_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("没有额外要求,围绕当前底稿自然扩展。"); let anchor_summary = build_anchor_summary( draft_profile, payload.anchor_card_ids.as_deref().unwrap_or(&[]), ); let creator_intent_summary = session .anchor_pack .get("creatorIntentSummary") .and_then(JsonValue::as_str) .or_else(|| { session .creator_intent .get("worldHook") .and_then(JsonValue::as_str) }) .or_else(|| draft_profile.get("summary").and_then(JsonValue::as_str)) .unwrap_or_default(); let world_name = draft_profile .get("name") .and_then(JsonValue::as_str) .unwrap_or("未命名世界"); let world_summary = draft_profile .get("summary") .and_then(JsonValue::as_str) .unwrap_or_default(); let (system_prompt, user_prompt, result_key) = match action { "generate_characters" => ( CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams { world_name, world_summary, creator_intent_summary, anchor_summary: anchor_summary.as_str(), existing_names: existing_character_names(draft_profile), count, prompt_seed, }), "generatedCharacters", ), "generate_landmarks" => ( CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, build_custom_world_agent_landmark_expansion_prompt(ExpansionPromptParams { world_name, world_summary, creator_intent_summary, anchor_summary: anchor_summary.as_str(), existing_names: existing_landmark_names(draft_profile), count, prompt_seed, }), "generatedLandmarks", ), _ => return Err(format!("unsupported generated entity action: {action}")), }; let response = llm_client .request_text(LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ])) .await .map_err(|error| format!("{action} LLM 请求失败:{error}"))?; let generated_entities = parse_json_array_response(response.content.as_str()) .map_err(|error| format!("{action} JSON 解析失败:{error}"))?; let normalized_entities = normalize_generated_entities(action, generated_entities, draft_profile, count); let payload_json = build_generated_entities_action_payload_json(payload, result_key, normalized_entities) .map_err(|error| match error { CustomWorldGeneratedEntitiesPayloadError::SerializePayload(message) => message, CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape => { "action payload 必须是 object".to_string() } })?; Ok(CustomWorldGeneratedEntitiesResult { payload_json }) } pub fn build_generated_entities_action_payload_json( payload: &ExecuteCustomWorldAgentActionRequest, result_key: &str, generated_entities: Vec, ) -> Result { let mut payload_value = serde_json::to_value(payload).map_err(|error| { CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!( "action payload JSON 序列化失败:{error}" )) })?; let payload_object = payload_value .as_object_mut() .ok_or(CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape)?; if payload.action.trim() == "generate_characters" { payload_object.insert( "roleType".to_string(), JsonValue::String(resolve_role_type(payload.role_type.as_deref()).to_string()), ); } payload_object.insert(result_key.to_string(), JsonValue::Array(generated_entities)); serde_json::to_string(&payload_value).map_err(|error| { CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!( "action payload JSON 序列化失败:{error}" )) }) } struct ExpansionPromptParams<'a> { world_name: &'a str, world_summary: &'a str, creator_intent_summary: &'a str, anchor_summary: &'a str, existing_names: Vec, count: u32, prompt_seed: &'a str, } fn build_custom_world_agent_character_expansion_prompt( params: ExpansionPromptParams<'_>, ) -> String { [ format!("当前世界:{}", params.world_name), format!("世界摘要:{}", params.world_summary), format!("创作意图摘要:{}", params.creator_intent_summary), format!("参考锚点:{}", params.anchor_summary), format!( "已有角色:{}", if params.existing_names.is_empty() { "暂无".to_string() } else { params.existing_names.join("、") } ), format!("数量:{}", params.count), format!( "补充要求:{}", if params.prompt_seed.trim().is_empty() { "没有额外要求,围绕当前底稿自然扩展。" } else { params.prompt_seed } ), "返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。".to_string(), "threadIds 必须优先引用现有线程 id。".to_string(), ] .join("\n") } fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptParams<'_>) -> String { [ format!("当前世界:{}", params.world_name), format!("世界摘要:{}", params.world_summary), format!("创作意图摘要:{}", params.creator_intent_summary), format!("参考锚点:{}", params.anchor_summary), format!( "已有地点:{}", if params.existing_names.is_empty() { "暂无".to_string() } else { params.existing_names.join("、") } ), format!("数量:{}", params.count), format!( "补充要求:{}", if params.prompt_seed.trim().is_empty() { "没有额外要求,围绕当前底稿自然扩展。" } else { params.prompt_seed } ), "返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, secret, summary, threadIds, characterIds。".to_string(), "threadIds / characterIds 必须优先引用现有对象 id。".to_string(), ] .join("\n") } fn ensure_count(count: Option) -> u32 { count.unwrap_or(1).clamp(1, 3) } fn resolve_role_type(role_type: Option<&str>) -> &'static str { match role_type.map(str::trim) { Some("playable") => "playable", _ => "story", } } fn parse_json_array_response(text: &str) -> Result, 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::>(&trimmed[start..=end]); } serde_json::from_str::>(trimmed) } fn normalize_generated_entities( action: &str, entities: Vec, draft_profile: &JsonMap, count: u32, ) -> Vec { let mut existing_names = if action == "generate_characters" { existing_character_names(draft_profile) } else { existing_landmark_names(draft_profile) }; entities .into_iter() .filter_map(|entry| entry.as_object().cloned()) .filter_map(|mut object| { let name = object .get("name") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty())? .to_string(); if existing_names.iter().any(|entry| entry == &name) { return None; } existing_names.push(name.clone()); let prefix = if action == "generate_characters" { "character" } else { "landmark" }; object.entry("id".to_string()).or_insert_with(|| { JsonValue::String(create_stable_id( prefix, name.as_str(), existing_names.len(), )) }); normalize_generated_entity_profile_fields(action, &mut object); Some(JsonValue::Object(object)) }) .take(count as usize) .collect() } fn normalize_generated_entity_profile_fields( action: &str, object: &mut JsonMap, ) { if action == "generate_characters" { normalize_generated_character_profile_fields(object); } else { normalize_generated_landmark_profile_fields(object); } } fn normalize_generated_character_profile_fields(object: &mut JsonMap) { let name = read_object_text(object, "name").unwrap_or_else(|| "新场景角色".to_string()); let role = read_object_text(object, "role").unwrap_or_else(|| "场景角色".to_string()); let summary = read_first_object_text(object, &["description", "summary", "publicMask"]) .unwrap_or_else(|| format!("{name}是围绕当前世界新补出的{role}。")); let hidden_hook = read_object_text(object, "hiddenHook").unwrap_or_else(|| summary.clone()); insert_text_if_missing(object, "title", role.as_str()); insert_text_if_missing(object, "description", summary.as_str()); insert_text_if_missing(object, "backstory", hidden_hook.as_str()); insert_text_if_missing(object, "personality", "待在后续互动中揭示"); insert_text_if_missing(object, "motivation", hidden_hook.as_str()); insert_text_if_missing(object, "combatStyle", "围绕自身身份采取行动"); object .entry("initialAffinity".to_string()) .or_insert_with(|| JsonValue::Number(6.into())); if !object .get("relationshipHooks") .is_some_and(JsonValue::is_array) { let mut hooks = Vec::new(); if let Some(relation) = read_object_text(object, "relationToPlayer") { hooks.push(JsonValue::String(relation)); } if hooks.is_empty() { hooks.push(JsonValue::String("等待玩家接触".to_string())); } object.insert("relationshipHooks".to_string(), JsonValue::Array(hooks)); } if !object.get("tags").is_some_and(JsonValue::is_array) { object.insert( "tags".to_string(), JsonValue::Array(vec![JsonValue::String(role)]), ); } } fn normalize_generated_landmark_profile_fields(object: &mut JsonMap) { let name = read_object_text(object, "name").unwrap_or_else(|| "新场景".to_string()); let description = read_first_object_text(object, &["description", "summary", "purpose"]) .unwrap_or_else(|| format!("{name}是围绕当前世界新补出的关键场景。")); let mut description_parts = vec![description]; for key in ["mood", "secret"] { if let Some(text) = read_object_text(object, key) && !description_parts.iter().any(|entry| entry == &text) { description_parts.push(text); } } insert_text_if_missing(object, "description", description_parts.join(" ").as_str()); object .entry("connections".to_string()) .or_insert_with(|| JsonValue::Array(Vec::new())); if !object.get("sceneNpcIds").is_some_and(JsonValue::is_array) { let npc_ids = object .get("characterIds") .and_then(JsonValue::as_array) .map(|items| { items .iter() .filter_map(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| JsonValue::String(value.to_string())) .collect::>() }) .unwrap_or_default(); object.insert("sceneNpcIds".to_string(), JsonValue::Array(npc_ids)); } } fn read_object_text(object: &JsonMap, key: &str) -> Option { object .get(key) .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn read_first_object_text(object: &JsonMap, keys: &[&str]) -> Option { keys.iter().find_map(|key| read_object_text(object, key)) } fn insert_text_if_missing(object: &mut JsonMap, key: &str, value: &str) { if read_object_text(object, key).is_none() { object.insert(key.to_string(), JsonValue::String(value.to_string())); } } fn existing_character_names(draft_profile: &JsonMap) -> Vec { ["playableNpcs", "storyNpcs"] .into_iter() .flat_map(|key| object_array_names(draft_profile.get(key))) .take(10) .collect() } fn existing_landmark_names(draft_profile: &JsonMap) -> Vec { object_array_names(draft_profile.get("landmarks")) .into_iter() .take(10) .collect() } fn object_array_names(value: Option<&JsonValue>) -> Vec { value .and_then(JsonValue::as_array) .into_iter() .flatten() .filter_map(|entry| entry.get("name").and_then(JsonValue::as_str)) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect() } fn build_anchor_summary( draft_profile: &JsonMap, anchor_card_ids: &[String], ) -> String { let selected = anchor_card_ids .iter() .filter_map(|card_id| find_anchor_summary(draft_profile, card_id)) .collect::>(); if !selected.is_empty() { return selected.join(";"); } draft_profile .get("summary") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("围绕当前世界底稿自然扩展。") .to_string() } fn find_anchor_summary( draft_profile: &JsonMap, card_id: &str, ) -> Option { for key in ["playableNpcs", "storyNpcs", "landmarks", "threads"] { for entry in draft_profile.get(key)?.as_array()? { let object = entry.as_object()?; let id = object .get("id") .and_then(JsonValue::as_str) .unwrap_or_default(); if id != card_id { continue; } let name = object .get("name") .and_then(JsonValue::as_str) .unwrap_or_default(); let summary = object .get("summary") .or_else(|| object.get("description")) .or_else(|| object.get("publicMask")) .and_then(JsonValue::as_str) .unwrap_or_default(); return Some(format!("{name}:{summary}")); } } None } fn create_stable_id(prefix: &str, name: &str, index: usize) -> String { let slug = name .trim() .to_lowercase() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch) { ch } else { '-' } }) .collect::() .trim_matches('-') .to_string(); format!( "{prefix}-{}-{index}", if slug.is_empty() { "entry" } else { slug.as_str() } ) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn character_expansion_prompt_keeps_node_contract_text() { let prompt = build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams { world_name: "雾港归航", world_summary: "守灯人追查旧案。", creator_intent_summary: "悬疑航海", anchor_summary: "旧灯塔:灯火错位", existing_names: vec!["岑灯".to_string()], count: 2, prompt_seed: "补一个敌对角色", }); assert!(prompt.contains("返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。")); assert!(prompt.contains("threadIds 必须优先引用现有线程 id。")); } #[test] fn generated_entities_payload_injects_expected_key() { let payload = ExecuteCustomWorldAgentActionRequest { action: "generate_landmarks".to_string(), profile_id: None, draft_profile: None, legacy_result_profile: None, setting_text: None, card_id: None, sections: None, profile: None, count: Some(1), role_type: None, prompt_text: Some("补地点".to_string()), anchor_card_ids: None, role_ids: None, role_id: None, portrait_path: None, generated_visual_asset_id: None, generated_animation_set_id: None, animation_map: None, scene_ids: None, scene_id: None, scene_kind: None, image_src: None, generated_scene_asset_id: None, generated_scene_prompt: None, generated_scene_model: None, checkpoint_id: None, }; let payload_json = build_generated_entities_action_payload_json( &payload, "generatedLandmarks", vec![json!({ "name": "沉船湾" })], ) .expect("payload should build"); let value = serde_json::from_str::(&payload_json).expect("payload should parse"); assert_eq!(value.get("action"), Some(&json!("generate_landmarks"))); assert_eq!( value .get("generatedLandmarks") .and_then(JsonValue::as_array) .map(Vec::len), Some(1) ); } #[test] fn generated_character_payload_fills_result_profile_fields() { let draft_profile = json!({ "playableNpcs": [], "storyNpcs": [], "landmarks": [] }); let entities = normalize_generated_entities( "generate_characters", vec![json!({ "name": "潮雾证人", "role": "旧案目击者", "publicMask": "总在码头边缘售卖旧航图。", "hiddenHook": "他记得沉钟第一次响起时失踪的人。", "relationToPlayer": "掌握玩家亲族旧案线索" })], draft_profile .as_object() .expect("draft profile should be object"), 1, ); let character = entities[0] .as_object() .expect("generated character should be object"); assert_eq!( character.get("description").and_then(JsonValue::as_str), Some("总在码头边缘售卖旧航图。") ); assert_eq!( character.get("backstory").and_then(JsonValue::as_str), Some("他记得沉钟第一次响起时失踪的人。") ); assert_eq!( character .get("relationshipHooks") .and_then(JsonValue::as_array) .and_then(|items| items.first()) .and_then(JsonValue::as_str), Some("掌握玩家亲族旧案线索") ); } #[test] fn generated_landmark_payload_fills_scene_profile_fields() { let draft_profile = json!({ "playableNpcs": [], "storyNpcs": [{ "id": "character-witness", "name": "潮雾证人" }], "landmarks": [] }); let entities = normalize_generated_entities( "generate_landmarks", vec![json!({ "name": "沉钟码头", "purpose": "玩家第一次追查沉钟旧案的入口。", "mood": "潮湿、压抑、灯火忽明忽暗。", "secret": "码头木桩下藏着改写航道的符牌。", "characterIds": ["character-witness"] })], draft_profile .as_object() .expect("draft profile should be object"), 1, ); let landmark = entities[0] .as_object() .expect("generated landmark should be object"); assert!( landmark .get("description") .and_then(JsonValue::as_str) .is_some_and(|text| text.contains("沉钟旧案") && text.contains("符牌")) ); assert_eq!( landmark .get("sceneNpcIds") .and_then(JsonValue::as_array) .and_then(|items| items.first()) .and_then(JsonValue::as_str), Some("character-witness") ); } }