Files
Genarrative/server-rs/crates/api-server/src/custom_world_agent_entities.rs
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

641 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CustomWorldGeneratedEntitiesResult, String> {
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<JsonValue>,
) -> Result<String, CustomWorldGeneratedEntitiesPayloadError> {
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<String>,
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>) -> 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<Vec<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::<Vec<JsonValue>>(&trimmed[start..=end]);
}
serde_json::from_str::<Vec<JsonValue>>(trimmed)
}
fn normalize_generated_entities(
action: &str,
entities: Vec<JsonValue>,
draft_profile: &JsonMap<String, JsonValue>,
count: u32,
) -> Vec<JsonValue> {
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<String, JsonValue>,
) {
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<String, JsonValue>) {
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<String, JsonValue>) {
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::<Vec<_>>()
})
.unwrap_or_default();
object.insert("sceneNpcIds".to_string(), JsonValue::Array(npc_ids));
}
}
fn read_object_text(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
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<String, JsonValue>, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| read_object_text(object, key))
}
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, 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<String, JsonValue>) -> Vec<String> {
["playableNpcs", "storyNpcs"]
.into_iter()
.flat_map(|key| object_array_names(draft_profile.get(key)))
.take(10)
.collect()
}
fn existing_landmark_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
object_array_names(draft_profile.get("landmarks"))
.into_iter()
.take(10)
.collect()
}
fn object_array_names(value: Option<&JsonValue>) -> Vec<String> {
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<String, JsonValue>,
anchor_card_ids: &[String],
) -> String {
let selected = anchor_card_ids
.iter()
.filter_map(|card_id| find_anchor_summary(draft_profile, card_id))
.collect::<Vec<_>>();
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<String, JsonValue>,
card_id: &str,
) -> Option<String> {
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::<String>()
.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::<JsonValue>(&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")
);
}
}