1997 lines
84 KiB
Rust
1997 lines
84 KiB
Rust
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CustomWorldFoundationDraftResult {
|
||
pub draft_profile_json: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum DraftFoundationPayloadError {
|
||
SerializePayload(String),
|
||
InvalidPayloadShape,
|
||
InvalidGeneratedDraft(String),
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CustomWorldFoundationDraftProgress {
|
||
pub phase_label: String,
|
||
pub phase_detail: String,
|
||
pub progress: u32,
|
||
}
|
||
|
||
pub async fn generate_custom_world_foundation_draft(
|
||
llm_client: &LlmClient,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
|
||
) -> Result<CustomWorldFoundationDraftResult, String> {
|
||
let setting_text = build_foundation_generation_seed_text(session);
|
||
emit_foundation_draft_progress(
|
||
&mut on_progress,
|
||
"整理世界骨架",
|
||
"正在根据创作者锚点生成第一版世界框架。",
|
||
12,
|
||
);
|
||
let mut framework = request_foundation_json_stage(
|
||
llm_client,
|
||
build_custom_world_framework_prompt(setting_text.as_str()),
|
||
"agent-foundation-framework",
|
||
|response_text| build_custom_world_framework_json_repair_prompt(response_text),
|
||
"agent-foundation-framework-json-repair",
|
||
"世界框架阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
normalize_framework_shape(&mut framework, setting_text.as_str());
|
||
|
||
let playable_outlines = generate_foundation_role_outline_entries(
|
||
llm_client,
|
||
&framework,
|
||
"playable",
|
||
FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||
(16, 30),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone());
|
||
|
||
let story_outlines = generate_foundation_role_outline_entries(
|
||
llm_client,
|
||
&framework,
|
||
"story",
|
||
FOUNDATION_DRAFT_STORY_COUNT,
|
||
(30, 44),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
|
||
|
||
let landmark_seeds = generate_foundation_landmark_seed_entries(
|
||
llm_client,
|
||
&framework,
|
||
FOUNDATION_DRAFT_LANDMARK_COUNT,
|
||
(44, 56),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
framework["landmarks"] = JsonValue::Array(landmark_seeds.clone());
|
||
|
||
let landmarks = expand_foundation_landmark_network_entries(
|
||
llm_client,
|
||
&framework,
|
||
&story_outlines,
|
||
&landmark_seeds,
|
||
(56, 66),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
framework["landmarks"] = JsonValue::Array(landmarks.clone());
|
||
|
||
let playable_narrative = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"playable",
|
||
&playable_outlines,
|
||
"narrative",
|
||
(66, 76),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let playable_detailed = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"playable",
|
||
&playable_narrative,
|
||
"dossier",
|
||
(76, 84),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let story_narrative = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"story",
|
||
&story_outlines,
|
||
"narrative",
|
||
(84, 92),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
let story_detailed = expand_foundation_role_entries(
|
||
llm_client,
|
||
&framework,
|
||
"story",
|
||
&story_narrative,
|
||
"dossier",
|
||
(92, 96),
|
||
&mut on_progress,
|
||
)
|
||
.await?;
|
||
|
||
emit_foundation_draft_progress(
|
||
&mut on_progress,
|
||
"编译世界底稿",
|
||
"正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。",
|
||
97,
|
||
);
|
||
|
||
let draft_profile = build_foundation_draft_profile_from_framework(
|
||
framework,
|
||
playable_detailed,
|
||
story_detailed,
|
||
landmarks,
|
||
session,
|
||
setting_text.as_str(),
|
||
);
|
||
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
|
||
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
|
||
|
||
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
|
||
}
|
||
|
||
const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT: &str = "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。";
|
||
const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT: &str = "你是 JSON 修复器。\n你会收到一段本应为单个 JSON 对象的文本。\n你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。\n不要输出 Markdown、代码块、解释、注释或额外文字。";
|
||
const FOUNDATION_DRAFT_PLAYABLE_COUNT: usize = 1;
|
||
const FOUNDATION_DRAFT_STORY_COUNT: usize = 8;
|
||
const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2;
|
||
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2;
|
||
const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2;
|
||
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2;
|
||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES: [i64; 4] = [15, 30, 60, 90];
|
||
|
||
async fn request_foundation_json_stage<F>(
|
||
llm_client: &LlmClient,
|
||
user_prompt: String,
|
||
debug_label: &str,
|
||
repair_prompt_builder: F,
|
||
repair_debug_label: &str,
|
||
empty_response_message: &str,
|
||
) -> Result<JsonValue, String>
|
||
where
|
||
F: Fn(&str) -> String,
|
||
{
|
||
let response = llm_client
|
||
.request_text(LlmTextRequest::new(vec![
|
||
LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT),
|
||
LlmMessage::user(user_prompt),
|
||
]))
|
||
.await
|
||
.map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?;
|
||
let text = response.content.trim();
|
||
if text.is_empty() {
|
||
return Err(empty_response_message.to_string());
|
||
}
|
||
match parse_json_response_text(text) {
|
||
Ok(value) => Ok(value),
|
||
Err(_) => {
|
||
let repaired = llm_client
|
||
.request_text(LlmTextRequest::new(vec![
|
||
LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT),
|
||
LlmMessage::user(repair_prompt_builder(text)),
|
||
]))
|
||
.await
|
||
.map_err(|error| format!("{repair_debug_label} LLM 请求失败:{error}"))?;
|
||
parse_json_response_text(repaired.content.as_str())
|
||
.map_err(|error| format!("{repair_debug_label} JSON 解析失败:{error}"))
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn generate_foundation_role_outline_entries(
|
||
llm_client: &LlmClient,
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
total_count: usize,
|
||
progress_range: (u32, u32),
|
||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||
) -> Result<Vec<JsonValue>, String> {
|
||
let mut merged_entries = Vec::new();
|
||
let planned_batch_count = total_count
|
||
.div_ceil(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE)
|
||
.max(1);
|
||
for batch_index in 0..planned_batch_count {
|
||
if merged_entries.len() >= total_count {
|
||
break;
|
||
}
|
||
let batch_count =
|
||
(total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE);
|
||
let forbidden_names = names_from_entries(&merged_entries);
|
||
let role_label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
format!("生成{role_label}").as_str(),
|
||
format!(
|
||
"正在生成{role_label}第 {} / {} 批,当前已完成 {}/{}。",
|
||
batch_index + 1,
|
||
planned_batch_count,
|
||
merged_entries.len(),
|
||
total_count,
|
||
)
|
||
.as_str(),
|
||
to_batch_progress(progress_range, merged_entries.len(), total_count),
|
||
);
|
||
let raw = request_foundation_json_stage(
|
||
llm_client,
|
||
build_custom_world_role_outline_batch_prompt(
|
||
framework,
|
||
role_type,
|
||
batch_count,
|
||
&forbidden_names,
|
||
),
|
||
format!(
|
||
"agent-foundation-{role_type}-outline-batch-{}",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
|response_text| {
|
||
build_custom_world_role_outline_batch_json_repair_prompt(
|
||
response_text,
|
||
role_type,
|
||
batch_count,
|
||
&forbidden_names,
|
||
)
|
||
},
|
||
format!(
|
||
"agent-foundation-{role_type}-outline-batch-{}-json-repair",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
"角色框架名单阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
let key = role_key(role_type);
|
||
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count));
|
||
}
|
||
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(
|
||
llm_client: &LlmClient,
|
||
framework: &JsonValue,
|
||
total_count: usize,
|
||
progress_range: (u32, u32),
|
||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||
) -> Result<Vec<JsonValue>, String> {
|
||
let mut merged_entries = Vec::new();
|
||
let planned_batch_count = total_count.div_ceil(FOUNDATION_LANDMARK_BATCH_SIZE).max(1);
|
||
for batch_index in 0..planned_batch_count {
|
||
if merged_entries.len() >= total_count {
|
||
break;
|
||
}
|
||
let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
|
||
let forbidden_names = names_from_entries(&merged_entries);
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
"生成关键场景",
|
||
format!(
|
||
"正在生成关键场景第 {} / {} 批,当前已完成 {}/{}。",
|
||
batch_index + 1,
|
||
planned_batch_count,
|
||
merged_entries.len(),
|
||
total_count,
|
||
)
|
||
.as_str(),
|
||
to_batch_progress(progress_range, merged_entries.len(), total_count),
|
||
);
|
||
let raw = request_foundation_json_stage(
|
||
llm_client,
|
||
build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names),
|
||
format!("agent-foundation-landmark-seed-batch-{}", batch_index + 1).as_str(),
|
||
|response_text| {
|
||
build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||
response_text,
|
||
batch_count,
|
||
&forbidden_names,
|
||
)
|
||
},
|
||
format!(
|
||
"agent-foundation-landmark-seed-batch-{}-json-repair",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
"地点框架名单阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count));
|
||
}
|
||
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(
|
||
llm_client: &LlmClient,
|
||
framework: &JsonValue,
|
||
story_npcs: &[JsonValue],
|
||
base_entries: &[JsonValue],
|
||
progress_range: (u32, u32),
|
||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||
) -> Result<Vec<JsonValue>, String> {
|
||
let mut merged_entries = Vec::new();
|
||
let batches: Vec<&[JsonValue]> = base_entries
|
||
.chunks(FOUNDATION_LANDMARK_BATCH_SIZE)
|
||
.collect();
|
||
let mut processed_count = 0usize;
|
||
for (batch_index, batch) in batches.iter().enumerate() {
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
"建立场景连接",
|
||
format!(
|
||
"正在补全场景连接第 {} / {} 批,当前已完成 {}/{}。",
|
||
batch_index + 1,
|
||
batches.len(),
|
||
processed_count,
|
||
base_entries.len(),
|
||
)
|
||
.as_str(),
|
||
to_batch_progress(progress_range, processed_count, base_entries.len()),
|
||
);
|
||
let raw = request_foundation_json_stage(
|
||
llm_client,
|
||
build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch),
|
||
format!(
|
||
"agent-foundation-landmark-network-batch-{}",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
|response_text| {
|
||
build_custom_world_landmark_network_batch_json_repair_prompt(
|
||
response_text,
|
||
&names_from_entries(batch),
|
||
)
|
||
},
|
||
format!(
|
||
"agent-foundation-landmark-network-batch-{}-json-repair",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
"地点网络补全阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
merged_entries.extend(array_field(&raw, "landmarks"));
|
||
processed_count = processed_count
|
||
.saturating_add(batch.len())
|
||
.min(base_entries.len());
|
||
}
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
"建立场景连接",
|
||
"关键场景的角色分布与路径连接已经整理完成。",
|
||
progress_range.1,
|
||
);
|
||
Ok(merge_entries_by_name(base_entries, &merged_entries))
|
||
}
|
||
|
||
async fn expand_foundation_role_entries(
|
||
llm_client: &LlmClient,
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
base_entries: &[JsonValue],
|
||
stage: &str,
|
||
progress_range: (u32, u32),
|
||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||
) -> Result<Vec<JsonValue>, String> {
|
||
let mut merged_entries = Vec::new();
|
||
let batches: Vec<&[JsonValue]> = base_entries
|
||
.chunks(FOUNDATION_ROLE_DETAIL_BATCH_SIZE)
|
||
.collect();
|
||
let mut processed_count = 0usize;
|
||
for (batch_index, batch) in batches.iter().enumerate() {
|
||
let expected_names = names_from_entries(batch);
|
||
let role_label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
let stage_label = if stage == "narrative" {
|
||
"叙事基础"
|
||
} else {
|
||
"档案细节"
|
||
};
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
format!("补全{role_label}{stage_label}").as_str(),
|
||
format!(
|
||
"正在补全{role_label}{stage_label}第 {} / {} 批,当前已完成 {}/{}。",
|
||
batch_index + 1,
|
||
batches.len(),
|
||
processed_count,
|
||
base_entries.len(),
|
||
)
|
||
.as_str(),
|
||
to_batch_progress(progress_range, processed_count, base_entries.len()),
|
||
);
|
||
let raw = request_foundation_json_stage(
|
||
llm_client,
|
||
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
|
||
format!(
|
||
"agent-foundation-{role_type}-{stage}-batch-{}",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
|response_text| {
|
||
build_custom_world_role_batch_json_repair_prompt(
|
||
response_text,
|
||
role_type,
|
||
stage,
|
||
&expected_names,
|
||
)
|
||
},
|
||
format!(
|
||
"agent-foundation-{role_type}-{stage}-batch-{}-json-repair",
|
||
batch_index + 1
|
||
)
|
||
.as_str(),
|
||
"角色档案补全阶段没有返回有效内容。",
|
||
)
|
||
.await?;
|
||
merged_entries.extend(array_field(&raw, role_key(role_type)));
|
||
processed_count = processed_count
|
||
.saturating_add(batch.len())
|
||
.min(base_entries.len());
|
||
}
|
||
let role_label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
let stage_label = if stage == "narrative" {
|
||
"叙事基础"
|
||
} else {
|
||
"档案细节"
|
||
};
|
||
emit_foundation_draft_progress(
|
||
on_progress,
|
||
format!("补全{role_label}{stage_label}").as_str(),
|
||
format!("{role_label}{stage_label}已经整理完成。").as_str(),
|
||
progress_range.1,
|
||
);
|
||
Ok(merge_entries_by_name(base_entries, &merged_entries))
|
||
}
|
||
|
||
fn emit_foundation_draft_progress(
|
||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||
phase_label: &str,
|
||
phase_detail: &str,
|
||
progress: u32,
|
||
) {
|
||
on_progress(CustomWorldFoundationDraftProgress {
|
||
phase_label: phase_label.to_string(),
|
||
phase_detail: phase_detail.to_string(),
|
||
progress: progress.min(100),
|
||
});
|
||
}
|
||
|
||
fn to_batch_progress(progress_range: (u32, u32), completed: usize, total: usize) -> u32 {
|
||
if total == 0 {
|
||
return progress_range.1;
|
||
}
|
||
let start = progress_range.0 as f64;
|
||
let end = progress_range.1 as f64;
|
||
let ratio = (completed as f64 / total as f64).clamp(0.0, 1.0);
|
||
(start + (end - start) * ratio).round().clamp(0.0, 100.0) as u32
|
||
}
|
||
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
|
||
pub fn build_draft_foundation_action_payload_json(
|
||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||
draft_profile_json: &str,
|
||
) -> Result<String, DraftFoundationPayloadError> {
|
||
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
|
||
DraftFoundationPayloadError::SerializePayload(format!(
|
||
"action payload JSON 序列化失败:{error}"
|
||
))
|
||
})?;
|
||
let payload_object = payload_value
|
||
.as_object_mut()
|
||
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
|
||
let draft_profile_value =
|
||
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
|
||
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
|
||
"foundation draft JSON 非法:{error}"
|
||
))
|
||
})?;
|
||
if !draft_profile_value.is_object() {
|
||
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
|
||
"foundation draft JSON 必须是 object".to_string(),
|
||
));
|
||
}
|
||
|
||
payload_object.insert("draftProfile".to_string(), draft_profile_value);
|
||
serde_json::to_string(&payload_value).map_err(|error| {
|
||
DraftFoundationPayloadError::SerializePayload(format!(
|
||
"action payload JSON 序列化失败:{error}"
|
||
))
|
||
})
|
||
}
|
||
|
||
fn build_foundation_generation_seed_text(session: &CustomWorldAgentSessionRecord) -> String {
|
||
let anchor_text = build_eight_anchor_foundation_text(&session.anchor_content);
|
||
if !anchor_text.trim().is_empty() {
|
||
return anchor_text;
|
||
}
|
||
if let Some(summary) = session
|
||
.anchor_pack
|
||
.get("creatorIntentSummary")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
return summary.to_string();
|
||
}
|
||
let sections = [
|
||
json_path_text(&session.creator_intent, &["worldHook"])
|
||
.map(|value| format!("世界核心:{value}")),
|
||
json_path_text(&session.creator_intent, &["playerPremise"])
|
||
.map(|value| format!("玩家身份:{value}")),
|
||
json_path_text(&session.creator_intent, &["openingSituation"])
|
||
.map(|value| format!("开局处境:{value}")),
|
||
json_string_array(&session.creator_intent, "coreConflicts")
|
||
.map(|items| format!("核心冲突:{}", items.join("、"))),
|
||
json_string_array(&session.creator_intent, "iconicElements")
|
||
.map(|items| format!("标志元素:{}", items.join("、"))),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
if sections.trim().is_empty() {
|
||
session.seed_text.trim().to_string()
|
||
} else {
|
||
sections
|
||
}
|
||
}
|
||
|
||
fn build_eight_anchor_foundation_text(anchor_content: &JsonValue) -> String {
|
||
let mut sections = Vec::new();
|
||
for key in [
|
||
"worldPromise",
|
||
"playerEntryPoint",
|
||
"coreLoop",
|
||
"mainConflict",
|
||
"keyCharacters",
|
||
"keyPlaces",
|
||
"toneAndStyle",
|
||
"firstScene",
|
||
] {
|
||
if let Some(value) = anchor_content.get(key)
|
||
&& !value.is_null()
|
||
{
|
||
sections.push(format!("{key}:{}", compact_json_text(value)));
|
||
}
|
||
}
|
||
sections.join("\n")
|
||
}
|
||
|
||
fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||
[
|
||
"请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物和地图细节。".to_string(),
|
||
"玩家设定:".to_string(),
|
||
setting_text.trim().to_string(),
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
" \"name\": \"世界名称\",".to_string(),
|
||
" \"subtitle\": \"世界副标题\",".to_string(),
|
||
" \"summary\": \"世界概述\",".to_string(),
|
||
" \"tone\": \"世界基调\",".to_string(),
|
||
" \"playerGoal\": \"玩家核心目标\",".to_string(),
|
||
" \"templateWorldType\": \"WUXIA|XIANXIA\",".to_string(),
|
||
" \"majorFactions\": [\"势力甲\", \"势力乙\"],".to_string(),
|
||
" \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],".to_string(),
|
||
" \"camp\": {".to_string(),
|
||
" \"name\": \"开局归处名称\",".to_string(),
|
||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
|
||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||
" }".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 这一步只输出顶层 9 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。".to_string(),
|
||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
|
||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||
"- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].join("\n")
|
||
}
|
||
|
||
fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> String {
|
||
[
|
||
"下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。",
|
||
"请只输出修复后的 JSON 对象。",
|
||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
|
||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||
"camp 必须是对象,且包含:name、description、dangerLevel。",
|
||
"原始文本:",
|
||
response_text.trim(),
|
||
].join("\n")
|
||
}
|
||
|
||
fn build_custom_world_role_outline_batch_prompt(
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
batch_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
let label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
[
|
||
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
|
||
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 0),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些名字已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
format!(" \"{key}\": ["),
|
||
" {".to_string(),
|
||
" \"name\": \"角色名称\",".to_string(),
|
||
" \"title\": \"称号\",".to_string(),
|
||
" \"role\": \"身份\",".to_string(),
|
||
" \"description\": \"极简定位描述\",".to_string(),
|
||
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
|
||
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
|
||
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
|
||
" \"initialAffinity\": 18,".to_string(),
|
||
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
|
||
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
format!("- 必须生成恰好 {batch_count} 个{label}。"),
|
||
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
|
||
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
|
||
"- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
|
||
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
|
||
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
|
||
"- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(),
|
||
"- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(),
|
||
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
|
||
if role_type == "playable" { "- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。".to_string() } else { "- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。".to_string() },
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
fn build_custom_world_role_outline_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
role_type: &str,
|
||
expected_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
[
|
||
format!("下面这段文本本应是自定义世界{}框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("必须保留恰好 {expected_count} 个角色对象。"),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||
"每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||
"如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(),
|
||
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
fn build_custom_world_landmark_seed_batch_prompt(
|
||
framework: &JsonValue,
|
||
batch_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
[
|
||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架与默认生图描述。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 0),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("这些地点已经生成,禁止重复:{}", forbidden_names.join("、")) },
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
" \"landmarks\": [".to_string(),
|
||
" {".to_string(),
|
||
" \"name\": \"场景名称\",".to_string(),
|
||
" \"description\": \"场景极简描述\",".to_string(),
|
||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||
"- 每个地点只保留:name、description、visualDescription、dangerLevel。".to_string(),
|
||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
expected_count: usize,
|
||
forbidden_names: &[String],
|
||
) -> String {
|
||
[
|
||
"下面这段文本本应是自定义世界关键场景框架名单批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||
"每个地点只包含:name、description、visualDescription、dangerLevel。".to_string(),
|
||
"如果缺少字段:字符串补空字符串,dangerLevel 补 medium。".to_string(),
|
||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
fn build_custom_world_landmark_network_batch_prompt(
|
||
framework: &JsonValue,
|
||
story_npcs: &[JsonValue],
|
||
landmark_batch: &[JsonValue],
|
||
) -> String {
|
||
[
|
||
"请补全下面这一批关键场景的探索网络信息。".to_string(),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 10),
|
||
"可用场景角色名单:".to_string(),
|
||
names_from_entries(story_npcs).join("、"),
|
||
"本批场景:".to_string(),
|
||
compact_json_text(&JsonValue::Array(landmark_batch.to_vec())),
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
" \"landmarks\": [".to_string(),
|
||
" {".to_string(),
|
||
" \"name\": \"场景名称\",".to_string(),
|
||
" \"description\": \"场景描述\",".to_string(),
|
||
" \"dangerLevel\": \"low|medium|high|extreme\",".to_string(),
|
||
" \"sceneNpcNames\": [\"会在这里出现的角色名\"],".to_string(),
|
||
" \"connectedLandmarkNames\": [\"相邻或可通往的地点名\"],".to_string(),
|
||
" \"entryHook\": \"玩家进入这里时首先遇到的钩子\"".to_string(),
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
"- 必须只补全本批场景,name 必须与本批场景完全一致,不得增删改名。".to_string(),
|
||
"- sceneNpcNames 只能引用上方可用场景角色名单中的名字,每个地点 1 到 3 个。".to_string(),
|
||
"- connectedLandmarkNames 优先引用已知关键场景名称,每个地点 1 到 3 个。".to_string(),
|
||
"- entryHook 控制在 16 到 36 个汉字内。".to_string(),
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
fn build_custom_world_landmark_network_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
expected_names: &[String],
|
||
) -> String {
|
||
[
|
||
"下面这段文本本应是自定义世界关键场景探索网络补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。".to_string(),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||
format!("这个数组里只能保留这些地点名:{}。", expected_names.join("、")),
|
||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||
"每个地点都必须包含:name、description、dangerLevel、sceneNpcNames、connectedLandmarkNames、entryHook。".to_string(),
|
||
"如果缺少字段:字符串补空字符串,数组补空数组,dangerLevel 补 medium。".to_string(),
|
||
"不要新增名单外的地点。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].join("\n")
|
||
}
|
||
|
||
fn build_custom_world_role_batch_prompt(
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
role_batch: &[JsonValue],
|
||
stage: &str,
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
let label = if role_type == "playable" {
|
||
"可扮演角色"
|
||
} else {
|
||
"场景角色"
|
||
};
|
||
let stage_label = if stage == "narrative" {
|
||
"叙事档案"
|
||
} else {
|
||
"养成档案"
|
||
};
|
||
let required_fields = if stage == "narrative" {
|
||
"name、backstory、personality、motivation、combatStyle"
|
||
} else {
|
||
"name、backstoryReveal、skills、initialItems"
|
||
};
|
||
let template_extra = if stage == "narrative" {
|
||
[
|
||
" \"backstory\": \"公开背景\",",
|
||
" \"personality\": \"性格关键词\",",
|
||
" \"motivation\": \"当前动机\",",
|
||
" \"combatStyle\": \"行动或战斗风格\"",
|
||
]
|
||
.join("\n")
|
||
} else {
|
||
[
|
||
" \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },",
|
||
" \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],",
|
||
" \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]",
|
||
].join("\n")
|
||
};
|
||
[
|
||
format!("请为下面这一批{label}补全{stage_label}。"),
|
||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||
"世界核心信息:".to_string(),
|
||
build_framework_summary_text(framework, 10),
|
||
"本批角色:".to_string(),
|
||
build_role_outline_prompt_text(role_batch, framework, role_type),
|
||
"".to_string(),
|
||
"输出 JSON 模板:".to_string(),
|
||
"{".to_string(),
|
||
format!(" \"{key}\": ["),
|
||
" {".to_string(),
|
||
" \"name\": \"角色名称\",".to_string(),
|
||
template_extra,
|
||
" }".to_string(),
|
||
" ]".to_string(),
|
||
"}".to_string(),
|
||
"".to_string(),
|
||
"要求:".to_string(),
|
||
"- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。".to_string(),
|
||
format!("- 每个角色必须包含:{required_fields}。"),
|
||
if stage == "narrative" { "- backstory 控制在 32 到 80 个汉字内;personality、motivation、combatStyle 都要短而具体。".to_string() } else { format!("- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")) },
|
||
if stage == "narrative" { "- 不要输出 backstoryReveal、skills、initialItems。".to_string() } else { "- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。".to_string() },
|
||
"- 所有生成文本都必须使用中文。".to_string(),
|
||
"- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。".to_string(),
|
||
].into_iter().filter(|value| !value.is_empty()).collect::<Vec<_>>().join("\n")
|
||
}
|
||
|
||
fn build_custom_world_role_batch_json_repair_prompt(
|
||
response_text: &str,
|
||
role_type: &str,
|
||
stage: &str,
|
||
expected_names: &[String],
|
||
) -> String {
|
||
let key = role_key(role_type);
|
||
if stage == "narrative" {
|
||
return [
|
||
format!("下面这段文本本应是自定义世界{}叙事档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||
"每个角色都必须包含:name、backstory、personality、motivation、combatStyle。".to_string(),
|
||
"如果缺少字段:字符串补空字符串。".to_string(),
|
||
"不要输出 backstoryReveal、skills、initialItems,也不要新增名单外的角色。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].join("\n");
|
||
}
|
||
[
|
||
format!("下面这段文本本应是自定义世界{}档案补全批次的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。", if role_type == "playable" { "可扮演角色" } else { "场景角色" }),
|
||
"请只输出修复后的 JSON 对象。".to_string(),
|
||
format!("顶层必须只包含一个 {key} 数组。"),
|
||
format!("这个数组里只能保留这些角色名:{}。", expected_names.join("、")),
|
||
"名称必须与名单完全一致,不得增删改名;如果原文遗漏,可按名单顺序补齐占位对象。".to_string(),
|
||
"每个角色都必须包含:name、backstoryReveal、skills、initialItems。".to_string(),
|
||
format!("backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 {}。", CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.iter().map(i64::to_string).collect::<Vec<_>>().join("、")),
|
||
"skills 默认补成 3 个对象,每个对象包含 name、summary、style;initialItems 默认补成 3 个对象,每个对象包含 name、category、quantity、rarity、description、tags。".to_string(),
|
||
"不要输出 backstory、personality、motivation、combatStyle、landmarks,也不要新增名单外的角色。".to_string(),
|
||
"原始文本:".to_string(),
|
||
response_text.trim().to_string(),
|
||
].join("\n")
|
||
}
|
||
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
|
||
let anchor_content = to_pretty_json(&session.anchor_content);
|
||
let creator_intent = to_pretty_json(&session.creator_intent);
|
||
let anchor_pack = to_pretty_json(&session.anchor_pack);
|
||
let current_draft = if is_non_null_json(&session.draft_profile) {
|
||
to_pretty_json(&session.draft_profile)
|
||
} else {
|
||
"{}".to_string()
|
||
};
|
||
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
|
||
|
||
[
|
||
format!("seedText:{}", session.seed_text.trim()),
|
||
format!("当前 stage:{}", session.stage.trim()),
|
||
format!("当前 progressPercent:{}", session.progress_percent),
|
||
format!(
|
||
"当前最后一条 assistant 回复:{}",
|
||
session.last_assistant_reply.clone().unwrap_or_default()
|
||
),
|
||
format!("当前 anchorContent:\n{anchor_content}"),
|
||
format!("当前 creatorIntent:\n{creator_intent}"),
|
||
format!("当前 anchorPack:\n{anchor_pack}"),
|
||
format!("当前已有 draftProfile:\n{current_draft}"),
|
||
format!("当前 qualityFindings:\n{quality_findings}"),
|
||
"请直接返回第一版 foundation draft JSON。".to_string(),
|
||
"约束:".to_string(),
|
||
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
|
||
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
|
||
"3. coreConflicts 必须至少 1 条。".to_string(),
|
||
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
|
||
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
|
||
"6. sceneChapterBlueprints[*].acts[*].backgroundPromptText 必须逐幕生成,作为每一幕生成背景图时默认填入的场景画面描述,不要只生成一个全局场景背景提示词。".to_string(),
|
||
"7. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
|
||
]
|
||
.join("\n\n")
|
||
}
|
||
|
||
fn build_foundation_draft_profile_from_framework(
|
||
framework: JsonValue,
|
||
playable_detailed: Vec<JsonValue>,
|
||
story_detailed: Vec<JsonValue>,
|
||
landmarks: Vec<JsonValue>,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
setting_text: &str,
|
||
) -> JsonMap<String, JsonValue> {
|
||
let mut object = JsonMap::new();
|
||
object.insert(
|
||
"name".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "name")
|
||
.unwrap_or_else(|| derive_world_name(&JsonMap::new(), session)),
|
||
),
|
||
);
|
||
object.insert(
|
||
"subtitle".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "subtitle").unwrap_or_else(|| "世界底稿已生成".to_string()),
|
||
),
|
||
);
|
||
object.insert(
|
||
"summary".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "summary").unwrap_or_else(|| setting_text.to_string()),
|
||
),
|
||
);
|
||
object.insert(
|
||
"tone".to_string(),
|
||
JsonValue::String(json_text(&framework, "tone").unwrap_or_default()),
|
||
);
|
||
object.insert(
|
||
"playerGoal".to_string(),
|
||
JsonValue::String(json_text(&framework, "playerGoal").unwrap_or_default()),
|
||
);
|
||
object.insert(
|
||
"worldHook".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "summary")
|
||
.unwrap_or_else(|| derive_world_hook(&JsonMap::new(), session)),
|
||
),
|
||
);
|
||
object.insert(
|
||
"playerPremise".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "playerGoal")
|
||
.unwrap_or_else(|| derive_player_premise(&JsonMap::new(), session)),
|
||
),
|
||
);
|
||
object.insert(
|
||
"settingText".to_string(),
|
||
JsonValue::String(setting_text.to_string()),
|
||
);
|
||
object.insert(
|
||
"templateWorldType".to_string(),
|
||
JsonValue::String(
|
||
json_text(&framework, "templateWorldType").unwrap_or_else(|| "WUXIA".to_string()),
|
||
),
|
||
);
|
||
object.insert(
|
||
"majorFactions".to_string(),
|
||
framework
|
||
.get("majorFactions")
|
||
.cloned()
|
||
.unwrap_or_else(|| JsonValue::Array(Vec::new())),
|
||
);
|
||
object.insert(
|
||
"coreConflicts".to_string(),
|
||
framework.get("coreConflicts").cloned().unwrap_or_else(|| {
|
||
JsonValue::Array(vec![JsonValue::String(
|
||
"核心冲突仍需继续深化,但已经具备第一版主线推进方向。".to_string(),
|
||
)])
|
||
}),
|
||
);
|
||
object.insert("camp".to_string(), framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })));
|
||
object.insert(
|
||
"playableNpcs".to_string(),
|
||
JsonValue::Array(playable_detailed),
|
||
);
|
||
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
|
||
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
|
||
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
|
||
normalize_foundation_draft_profile(JsonValue::Object(object), session)
|
||
}
|
||
|
||
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||
if !framework.is_object() {
|
||
*framework = json!({});
|
||
}
|
||
let object = framework
|
||
.as_object_mut()
|
||
.expect("framework object should exist");
|
||
for key in [
|
||
"name",
|
||
"subtitle",
|
||
"summary",
|
||
"tone",
|
||
"playerGoal",
|
||
"templateWorldType",
|
||
] {
|
||
let value = object
|
||
.get(key)
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_default()
|
||
.to_string();
|
||
if value.is_empty() {
|
||
object.insert(
|
||
key.to_string(),
|
||
JsonValue::String(if key == "summary" {
|
||
setting_text.to_string()
|
||
} else {
|
||
String::new()
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
if !object.get("majorFactions").is_some_and(JsonValue::is_array) {
|
||
object.insert("majorFactions".to_string(), JsonValue::Array(Vec::new()));
|
||
}
|
||
if !object.get("coreConflicts").is_some_and(JsonValue::is_array) {
|
||
object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new()));
|
||
}
|
||
if !object.get("camp").is_some_and(JsonValue::is_object) {
|
||
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
|
||
}
|
||
}
|
||
|
||
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
|
||
let landmark_text = array_field(framework, "landmarks")
|
||
.into_iter()
|
||
.take(max_landmarks)
|
||
.map(|landmark| {
|
||
format!(
|
||
"{}({},{})",
|
||
json_text(&landmark, "name").unwrap_or_default(),
|
||
json_text(&landmark, "dangerLevel").unwrap_or_default(),
|
||
json_text(&landmark, "description").unwrap_or_default()
|
||
)
|
||
})
|
||
.filter(|value| !value.trim().is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("、");
|
||
[
|
||
format!("世界:{}", json_text(framework, "name").unwrap_or_default()),
|
||
format!(
|
||
"副标题:{}",
|
||
json_text(framework, "subtitle").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"世界概述:{}",
|
||
json_text(framework, "summary").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"世界基调:{}",
|
||
json_text(framework, "tone").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"玩家核心目标:{}",
|
||
json_text(framework, "playerGoal").unwrap_or_default()
|
||
),
|
||
json_string_array(framework, "majorFactions")
|
||
.map(|items| format!("主要势力:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
json_string_array(framework, "coreConflicts")
|
||
.map(|items| format!("核心冲突:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
format!(
|
||
"开局归处:{}({})",
|
||
json_path_text(framework, &["camp", "name"]).unwrap_or_default(),
|
||
json_path_text(framework, &["camp", "description"]).unwrap_or_default()
|
||
),
|
||
if landmark_text.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("关键场景:{landmark_text}")
|
||
},
|
||
]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn build_role_outline_prompt_text(
|
||
role_batch: &[JsonValue],
|
||
framework: &JsonValue,
|
||
role_type: &str,
|
||
) -> String {
|
||
role_batch
|
||
.iter()
|
||
.map(|role| {
|
||
let appearance_text = if role_type == "story" {
|
||
landmark_names_for_role(
|
||
framework,
|
||
json_text(role, "name").unwrap_or_default().as_str(),
|
||
)
|
||
.join("、")
|
||
} else {
|
||
String::new()
|
||
};
|
||
[
|
||
format!(
|
||
"- {} / {}",
|
||
json_text(role, "name").unwrap_or_default(),
|
||
json_text(role, "title").unwrap_or_default()
|
||
),
|
||
format!("身份:{}", json_text(role, "role").unwrap_or_default()),
|
||
format!(
|
||
"框架描述:{}",
|
||
json_text(role, "description").unwrap_or_default()
|
||
),
|
||
format!(
|
||
"预设好感:{}",
|
||
role.get("initialAffinity")
|
||
.and_then(JsonValue::as_i64)
|
||
.unwrap_or(0)
|
||
),
|
||
json_string_array(role, "relationshipHooks")
|
||
.map(|items| format!("关系切入口:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
json_string_array(role, "tags")
|
||
.map(|items| format!("标签:{}", items.join("、")))
|
||
.unwrap_or_default(),
|
||
if appearance_text.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("出现场景:{appearance_text}")
|
||
},
|
||
]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec<String> {
|
||
array_field(framework, "landmarks")
|
||
.into_iter()
|
||
.filter_map(|landmark| {
|
||
let names = json_string_array(&landmark, "sceneNpcNames").unwrap_or_default();
|
||
if names.iter().any(|name| name == role_name) {
|
||
json_text(&landmark, "name")
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn role_key(role_type: &str) -> &'static str {
|
||
if role_type == "playable" {
|
||
"playableNpcs"
|
||
} else {
|
||
"storyNpcs"
|
||
}
|
||
}
|
||
|
||
fn array_field(value: &JsonValue, key: &str) -> Vec<JsonValue> {
|
||
value
|
||
.get(key)
|
||
.and_then(JsonValue::as_array)
|
||
.cloned()
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn names_from_entries(entries: &[JsonValue]) -> Vec<String> {
|
||
entries
|
||
.iter()
|
||
.filter_map(|entry| json_text(entry, "name"))
|
||
.filter(|value| !value.is_empty())
|
||
.collect()
|
||
}
|
||
|
||
fn merge_entries_by_name(
|
||
base_entries: &[JsonValue],
|
||
patch_entries: &[JsonValue],
|
||
) -> Vec<JsonValue> {
|
||
base_entries
|
||
.iter()
|
||
.map(|base| {
|
||
let Some(name) = json_text(base, "name") else {
|
||
return base.clone();
|
||
};
|
||
let patch = patch_entries
|
||
.iter()
|
||
.find(|entry| json_text(entry, "name").as_deref() == Some(name.as_str()));
|
||
merge_json_objects(base, patch.unwrap_or(base))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn merge_json_objects(base: &JsonValue, patch: &JsonValue) -> JsonValue {
|
||
let mut object = base.as_object().cloned().unwrap_or_default();
|
||
if let Some(patch_object) = patch.as_object() {
|
||
for (key, value) in patch_object {
|
||
if !value.is_null() {
|
||
object.insert(key.clone(), value.clone());
|
||
}
|
||
}
|
||
}
|
||
JsonValue::Object(object)
|
||
}
|
||
|
||
fn json_text(value: &JsonValue, key: &str) -> Option<String> {
|
||
json_path_text(value, &[key])
|
||
}
|
||
|
||
fn json_path_text(value: &JsonValue, path: &[&str]) -> Option<String> {
|
||
let mut current = value;
|
||
for segment in path {
|
||
current = current.get(*segment)?;
|
||
}
|
||
current
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
}
|
||
|
||
fn json_string_array(value: &JsonValue, key: &str) -> Option<Vec<String>> {
|
||
let items = value
|
||
.get(key)?
|
||
.as_array()?
|
||
.iter()
|
||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||
.filter(|entry| !entry.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.collect::<Vec<_>>();
|
||
if items.is_empty() { None } else { Some(items) }
|
||
}
|
||
|
||
fn compact_json_text(value: &JsonValue) -> String {
|
||
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
|
||
}
|
||
fn normalize_foundation_draft_profile(
|
||
value: JsonValue,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
) -> JsonMap<String, JsonValue> {
|
||
let mut object = value.as_object().cloned().unwrap_or_default();
|
||
let fallback_title = derive_world_name(&object, session);
|
||
let fallback_world_hook = derive_world_hook(&object, session);
|
||
let fallback_player_premise = derive_player_premise(&object, session);
|
||
ensure_text_field(&mut object, "name", fallback_title.as_str());
|
||
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
|
||
ensure_text_field(
|
||
&mut object,
|
||
"summary",
|
||
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
|
||
);
|
||
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
|
||
ensure_text_field(
|
||
&mut object,
|
||
"playerPremise",
|
||
fallback_player_premise.as_str(),
|
||
);
|
||
ensure_text_array_field(
|
||
&mut object,
|
||
"coreConflicts",
|
||
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
|
||
);
|
||
ensure_object_array_field(&mut object, "playableNpcs");
|
||
ensure_object_array_field(&mut object, "storyNpcs");
|
||
ensure_object_array_field(&mut object, "landmarks");
|
||
ensure_object_array_field(&mut object, "chapters");
|
||
ensure_scene_chapter_blueprints(&mut object);
|
||
object
|
||
}
|
||
|
||
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
|
||
let current = object
|
||
.get(key)
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned);
|
||
object.insert(
|
||
key.to_string(),
|
||
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
|
||
);
|
||
}
|
||
|
||
fn ensure_text_array_field(
|
||
object: &mut JsonMap<String, JsonValue>,
|
||
key: &str,
|
||
fallback_items: Vec<&str>,
|
||
) {
|
||
let current_items = object
|
||
.get(key)
|
||
.and_then(JsonValue::as_array)
|
||
.map(|entries| {
|
||
entries
|
||
.iter()
|
||
.filter_map(|entry| entry.as_str().map(str::trim))
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| JsonValue::String(value.to_string()))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
if current_items.is_empty() {
|
||
object.insert(
|
||
key.to_string(),
|
||
JsonValue::Array(
|
||
fallback_items
|
||
.into_iter()
|
||
.map(|value| JsonValue::String(value.to_string()))
|
||
.collect(),
|
||
),
|
||
);
|
||
} else {
|
||
object.insert(key.to_string(), JsonValue::Array(current_items));
|
||
}
|
||
}
|
||
|
||
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
|
||
let current = object
|
||
.get(key)
|
||
.and_then(JsonValue::as_array)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
object.insert(key.to_string(), JsonValue::Array(current));
|
||
}
|
||
|
||
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
|
||
let blueprints = object
|
||
.get("sceneChapterBlueprints")
|
||
.and_then(JsonValue::as_array)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
if blueprints.is_empty() {
|
||
object.insert(
|
||
"sceneChapterBlueprints".to_string(),
|
||
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
|
||
);
|
||
return;
|
||
}
|
||
|
||
let normalized = blueprints
|
||
.into_iter()
|
||
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
|
||
.collect::<Vec<_>>();
|
||
object.insert(
|
||
"sceneChapterBlueprints".to_string(),
|
||
JsonValue::Array(normalized),
|
||
);
|
||
}
|
||
|
||
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||
let mut object = chapter.as_object().cloned().unwrap_or_default();
|
||
let title = object
|
||
.get("title")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or("第一幕");
|
||
object.insert("title".to_string(), JsonValue::String(title.to_string()));
|
||
let acts = object
|
||
.get("acts")
|
||
.and_then(JsonValue::as_array)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
if acts.is_empty() {
|
||
object.insert(
|
||
"acts".to_string(),
|
||
JsonValue::Array(vec![build_fallback_scene_act()]),
|
||
);
|
||
} else {
|
||
object.insert(
|
||
"acts".to_string(),
|
||
JsonValue::Array(
|
||
acts.into_iter()
|
||
.enumerate()
|
||
.map(|(index, act)| normalize_scene_act_blueprint(act, index))
|
||
.collect(),
|
||
),
|
||
);
|
||
}
|
||
JsonValue::Object(object)
|
||
}
|
||
|
||
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
|
||
let mut object = act.as_object().cloned().unwrap_or_default();
|
||
let fallback_act = build_fallback_scene_act_with_index(index);
|
||
let fallback_prompt = fallback_act
|
||
.get("backgroundPromptText")
|
||
.and_then(JsonValue::as_str)
|
||
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
|
||
.to_string();
|
||
let title = object
|
||
.get("title")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.unwrap_or_else(|| format!("第{}幕", index + 1));
|
||
let summary = object
|
||
.get("summary")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.unwrap_or_else(|| "当前幕推动场景内的主线压力。".to_string());
|
||
object.insert("title".to_string(), JsonValue::String(title.clone()));
|
||
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
|
||
let background_prompt = object
|
||
.get("backgroundPromptText")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.unwrap_or_else(|| format!("{title}:{summary}。{fallback_prompt}"));
|
||
object.insert(
|
||
"backgroundPromptText".to_string(),
|
||
JsonValue::String(background_prompt),
|
||
);
|
||
JsonValue::Object(object)
|
||
}
|
||
|
||
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
|
||
json!({
|
||
"id": "chapter-act-1",
|
||
"title": "第一幕",
|
||
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
|
||
"acts": [build_fallback_scene_act()],
|
||
})
|
||
}
|
||
|
||
fn build_fallback_scene_act() -> JsonValue {
|
||
build_fallback_scene_act_with_index(0)
|
||
}
|
||
|
||
fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
|
||
json!({
|
||
"id": format!("scene-act-{}", index + 1),
|
||
"title": if index == 0 { "开场场景幕".to_string() } else { format!("第{}幕", index + 1) },
|
||
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
|
||
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
|
||
})
|
||
}
|
||
|
||
fn derive_world_name(
|
||
object: &JsonMap<String, JsonValue>,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
) -> String {
|
||
read_text_field(object, &["name", "title"])
|
||
.or_else(|| {
|
||
session
|
||
.anchor_content
|
||
.get("worldPromise")
|
||
.and_then(JsonValue::as_object)
|
||
.and_then(|entry| entry.get("hook"))
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
})
|
||
.unwrap_or_else(|| "未命名世界草稿".to_string())
|
||
}
|
||
|
||
fn derive_world_hook(
|
||
object: &JsonMap<String, JsonValue>,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
) -> String {
|
||
read_text_field(object, &["worldHook"])
|
||
.or_else(|| {
|
||
session
|
||
.anchor_content
|
||
.get("worldPromise")
|
||
.and_then(JsonValue::as_object)
|
||
.and_then(|entry| entry.get("hook"))
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
})
|
||
.unwrap_or_else(|| {
|
||
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
|
||
})
|
||
}
|
||
|
||
fn derive_player_premise(
|
||
object: &JsonMap<String, JsonValue>,
|
||
session: &CustomWorldAgentSessionRecord,
|
||
) -> String {
|
||
read_text_field(object, &["playerPremise"])
|
||
.or_else(|| {
|
||
session
|
||
.anchor_content
|
||
.get("playerEntryPoint")
|
||
.and_then(JsonValue::as_object)
|
||
.map(|entry| {
|
||
let identity = entry
|
||
.get("openingIdentity")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.unwrap_or_default();
|
||
let problem = entry
|
||
.get("openingProblem")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.unwrap_or_default();
|
||
let motivation = entry
|
||
.get("entryMotivation")
|
||
.and_then(JsonValue::as_str)
|
||
.map(str::trim)
|
||
.unwrap_or_default();
|
||
[identity, problem, motivation]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join(";")
|
||
})
|
||
.filter(|value| !value.trim().is_empty())
|
||
})
|
||
.unwrap_or_else(|| {
|
||
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
|
||
.to_string()
|
||
})
|
||
}
|
||
|
||
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||
for key in keys {
|
||
let mut current = JsonValue::Object(object.clone());
|
||
let mut found = true;
|
||
for segment in key.split('.') {
|
||
if let Some(next) = current.get(segment) {
|
||
current = next.clone();
|
||
} else {
|
||
found = false;
|
||
break;
|
||
}
|
||
}
|
||
if found
|
||
&& let Some(value) = current
|
||
.as_str()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
return Some(value.to_string());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
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 to_pretty_json(value: &JsonValue) -> String {
|
||
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
|
||
}
|
||
|
||
fn is_non_null_json(value: &JsonValue) -> bool {
|
||
!matches!(value, JsonValue::Null)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::{
|
||
collections::VecDeque,
|
||
io::{Read, Write},
|
||
net::TcpListener,
|
||
sync::{Arc, Mutex},
|
||
thread,
|
||
time::Duration as StdDuration,
|
||
};
|
||
|
||
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn foundation_prompt_uses_real_seed_text() {
|
||
let session = build_test_session();
|
||
|
||
let prompt = build_foundation_draft_user_prompt(&session);
|
||
|
||
assert!(prompt.contains("seedText:海雾会吞掉记错航线的人。"));
|
||
assert!(!prompt.contains("seedText:custom-world-agent-session-1"));
|
||
}
|
||
|
||
#[test]
|
||
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
|
||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||
action: "draft_foundation".to_string(),
|
||
profile_id: Some("profile-1".to_string()),
|
||
draft_profile: Some(json!({ "name": "旧草稿" })),
|
||
legacy_result_profile: None,
|
||
setting_text: Some("旧设定".to_string()),
|
||
card_id: None,
|
||
sections: None,
|
||
profile: None,
|
||
count: None,
|
||
role_type: None,
|
||
prompt_text: Some("补充提示".to_string()),
|
||
anchor_card_ids: Some(vec!["card-1".to_string()]),
|
||
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_draft_foundation_action_payload_json(
|
||
&payload,
|
||
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
|
||
)
|
||
.expect("payload json should build");
|
||
let payload_value =
|
||
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
|
||
|
||
assert_eq!(
|
||
payload_value.get("action"),
|
||
Some(&json!("draft_foundation"))
|
||
);
|
||
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
|
||
assert_eq!(
|
||
payload_value
|
||
.get("draftProfile")
|
||
.and_then(|value| value.get("name")),
|
||
Some(&json!("新草稿"))
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
|
||
let request_capture = Arc::new(Mutex::new(Vec::new()));
|
||
let server_url = spawn_mock_server(
|
||
request_capture.clone(),
|
||
vec![
|
||
llm_response(
|
||
r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。","dangerLevel":"low"}}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high"}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","dangerLevel":"medium","sceneNpcNames":["灯童丁","档吏庚"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","dangerLevel":"high","sceneNpcNames":["船魂戊","潮医乙"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"playableNpcs":[{"name":"岑灯","backstoryReveal":{"publicSummary":"返乡守灯人的旧案羁绊。","chapters":[{"affinityRequired":15,"title":"返乡","summary":"回到旧灯塔。"},{"affinityRequired":30,"title":"旧档","summary":"发现档案错页。"},{"affinityRequired":60,"title":"沉船","summary":"接近沉船湾。"},{"affinityRequired":90,"title":"真相","summary":"直面议会遮掩。"}]},"skills":[{"name":"读灯","summary":"辨认灯火暗号","style":"侦查"}],"initialItems":[{"name":"旧海图","category":"道具","quantity":1,"rarity":"common","description":"父亲留下的海图。","tags":["线索"]}]}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#,
|
||
),
|
||
llm_response(
|
||
r#"{"storyNpcs":[{"name":"议长甲","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#,
|
||
),
|
||
],
|
||
);
|
||
let llm_client = build_test_llm_client(server_url);
|
||
let session = build_test_session();
|
||
|
||
let result = generate_custom_world_foundation_draft(&llm_client, &session, |_| {})
|
||
.await
|
||
.expect("draft generation should succeed");
|
||
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
|
||
.expect("draft profile should parse");
|
||
let captured_requests = request_capture
|
||
.lock()
|
||
.expect("request capture should lock")
|
||
.clone();
|
||
let request_text = captured_requests.join("\n---request---\n");
|
||
|
||
assert!(captured_requests.len() >= 18);
|
||
assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。"));
|
||
assert!(request_text.contains("世界核心骨架"));
|
||
assert!(request_text.contains("可扮演角色框架名单"));
|
||
assert!(request_text.contains("场景角色框架名单"));
|
||
assert!(request_text.contains("关键场景框架名单"));
|
||
assert!(request_text.contains("探索网络信息"));
|
||
assert!(request_text.contains("叙事档案"));
|
||
assert!(request_text.contains("养成档案"));
|
||
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
|
||
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
|
||
assert!(
|
||
draft_profile
|
||
.get("worldHook")
|
||
.and_then(JsonValue::as_str)
|
||
.is_some()
|
||
);
|
||
assert!(
|
||
draft_profile
|
||
.get("playerPremise")
|
||
.and_then(JsonValue::as_str)
|
||
.is_some()
|
||
);
|
||
assert_eq!(
|
||
draft_profile
|
||
.get("sceneChapterBlueprints")
|
||
.and_then(JsonValue::as_array)
|
||
.and_then(|entries| entries.first())
|
||
.and_then(|entry| entry.get("acts"))
|
||
.and_then(JsonValue::as_array)
|
||
.map(|entries| !entries.is_empty()),
|
||
Some(true)
|
||
);
|
||
}
|
||
|
||
fn llm_response(content: &str) -> String {
|
||
json!({
|
||
"id": "resp_01",
|
||
"choices": [
|
||
{
|
||
"message": {
|
||
"content": content,
|
||
}
|
||
}
|
||
]
|
||
})
|
||
.to_string()
|
||
}
|
||
|
||
fn build_test_session() -> CustomWorldAgentSessionRecord {
|
||
CustomWorldAgentSessionRecord {
|
||
session_id: "custom-world-agent-session-1".to_string(),
|
||
seed_text: "海雾会吞掉记错航线的人。".to_string(),
|
||
current_turn: 2,
|
||
anchor_content: json!({
|
||
"worldPromise": {
|
||
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
|
||
},
|
||
"playerEntryPoint": {
|
||
"openingIdentity": "被停职返乡的守灯人",
|
||
"openingProblem": "灯塔记录被人改写",
|
||
"entryMotivation": "查清父亲沉船真相"
|
||
}
|
||
}),
|
||
progress_percent: 100,
|
||
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
|
||
stage: "foundation_review".to_string(),
|
||
focus_card_id: None,
|
||
creator_intent: json!({
|
||
"theme": "悬疑航海",
|
||
"playerPremise": "玩家是返乡调查旧案的守灯人。"
|
||
}),
|
||
creator_intent_readiness: json!({
|
||
"isReady": true
|
||
}),
|
||
anchor_pack: json!({
|
||
"coreConflict": "群岛议会正在掩盖沉船真相。"
|
||
}),
|
||
lock_state: json!({}),
|
||
draft_profile: JsonValue::Null,
|
||
messages: Vec::new(),
|
||
draft_cards: Vec::new(),
|
||
pending_clarifications: Vec::new(),
|
||
suggested_actions: Vec::new(),
|
||
recommended_replies: Vec::new(),
|
||
quality_findings: Vec::new(),
|
||
asset_coverage: json!({}),
|
||
checkpoints: Vec::new(),
|
||
supported_actions: Vec::new(),
|
||
publish_gate: None,
|
||
result_preview: None,
|
||
updated_at: "2026-04-23T00:00:00Z".to_string(),
|
||
}
|
||
}
|
||
|
||
fn build_test_llm_client(base_url: String) -> LlmClient {
|
||
let config = LlmConfig::new(
|
||
LlmProvider::Ark,
|
||
base_url,
|
||
"test-key".to_string(),
|
||
"test-model".to_string(),
|
||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||
0,
|
||
1,
|
||
)
|
||
.expect("llm config should build");
|
||
|
||
LlmClient::new(config).expect("llm client should build")
|
||
}
|
||
|
||
fn spawn_mock_server(
|
||
request_capture: Arc<Mutex<Vec<String>>>,
|
||
response_bodies: Vec<String>,
|
||
) -> String {
|
||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||
let address = listener
|
||
.local_addr()
|
||
.expect("listener should expose address");
|
||
|
||
thread::spawn(move || {
|
||
let mut response_queue = VecDeque::from(response_bodies);
|
||
for _ in 0..32 {
|
||
let response_body = response_queue.pop_front().unwrap_or_else(|| {
|
||
llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#)
|
||
});
|
||
let (mut stream, _) = listener.accept().expect("request should connect");
|
||
let request_text = read_request(&mut stream);
|
||
request_capture
|
||
.lock()
|
||
.expect("request capture should lock")
|
||
.push(request_text);
|
||
write_response(&mut stream, response_body);
|
||
}
|
||
});
|
||
|
||
format!("http://{address}")
|
||
}
|
||
|
||
fn read_request(stream: &mut std::net::TcpStream) -> String {
|
||
stream
|
||
.set_read_timeout(Some(StdDuration::from_secs(1)))
|
||
.expect("read timeout should be configured");
|
||
let mut buffer = Vec::new();
|
||
let mut chunk = [0_u8; 1024];
|
||
let mut expected_total = None;
|
||
|
||
loop {
|
||
match stream.read(&mut chunk) {
|
||
Ok(0) => break,
|
||
Ok(bytes_read) => {
|
||
buffer.extend_from_slice(&chunk[..bytes_read]);
|
||
|
||
if expected_total.is_none()
|
||
&& let Some(header_end) = find_header_end(&buffer)
|
||
{
|
||
let content_length =
|
||
read_content_length(&buffer[..header_end]).unwrap_or(0);
|
||
expected_total = Some(header_end + content_length);
|
||
}
|
||
|
||
if let Some(total_bytes) = expected_total
|
||
&& buffer.len() >= total_bytes
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
Err(error)
|
||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||
{
|
||
break;
|
||
}
|
||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||
}
|
||
}
|
||
|
||
String::from_utf8(buffer).expect("request should be utf-8")
|
||
}
|
||
|
||
fn write_response(stream: &mut std::net::TcpStream, body: String) {
|
||
let raw_response = format!(
|
||
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
body.len(),
|
||
body
|
||
);
|
||
stream
|
||
.write_all(raw_response.as_bytes())
|
||
.expect("mock response should be written");
|
||
stream.flush().expect("mock response should flush");
|
||
}
|
||
|
||
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||
buffer
|
||
.windows(4)
|
||
.position(|window| window == b"\r\n\r\n")
|
||
.map(|index| index + 4)
|
||
}
|
||
|
||
fn read_content_length(headers: &[u8]) -> Option<usize> {
|
||
let text = String::from_utf8_lossy(headers);
|
||
text.lines().find_map(|line| {
|
||
let (name, value) = line.split_once(':')?;
|
||
if name.eq_ignore_ascii_case("content-length") {
|
||
return value.trim().parse::<usize>().ok();
|
||
}
|
||
None
|
||
})
|
||
}
|
||
}
|