1
This commit is contained in:
@@ -80,6 +80,7 @@ pub struct AppConfig {
|
||||
pub llm_request_timeout_ms: u64,
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
pub rpg_llm_web_search_enabled: bool,
|
||||
pub dashscope_base_url: String,
|
||||
pub dashscope_api_key: Option<String>,
|
||||
pub dashscope_scene_image_model: String,
|
||||
@@ -168,6 +169,7 @@ impl Default for AppConfig {
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
rpg_llm_web_search_enabled: true,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
|
||||
@@ -466,6 +468,13 @@ impl AppConfig {
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
if let Some(rpg_llm_web_search_enabled) = read_first_bool_env(&[
|
||||
"GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED",
|
||||
"RPG_LLM_WEB_SEARCH_ENABLED",
|
||||
]) {
|
||||
config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled;
|
||||
}
|
||||
|
||||
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
|
||||
config.dashscope_base_url = dashscope_base_url;
|
||||
}
|
||||
@@ -814,4 +823,24 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_SPACETIME_POOL_SIZE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_rpg_llm_web_search_switch() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(!config.rpg_llm_web_search_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +625,9 @@ fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||||
" \"camp\": {".to_string(),
|
||||
" \"name\": \"开局归处名称\",".to_string(),
|
||||
" \"description\": \"这是玩家进入世界后的第一处落脚点描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"开局第一幕背景画面描述\", \"开局第二幕背景画面描述\", \"开局第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"开局第一幕事件描述\", \"开局第二幕事件描述\", \"开局第三幕事件描述\"],".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
"}".to_string(),
|
||||
@@ -635,6 +638,9 @@ fn build_custom_world_framework_prompt(setting_text: &str) -> String {
|
||||
"- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。".to_string(),
|
||||
"- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。".to_string(),
|
||||
"- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。".to_string(),
|
||||
"- camp.sceneTaskDescription 必须描述玩家首次进入开局场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- camp.actBackgroundPromptTexts 必须恰好 3 条,分别对应开局场景第 1/2/3 幕背景图画面内容描述;每条都必须可直接交给生图模型,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- camp.actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。".to_string(),
|
||||
"- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。".to_string(),
|
||||
"- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。".to_string(),
|
||||
@@ -650,7 +656,7 @@ fn build_custom_world_framework_json_repair_prompt(response_text: &str) -> Strin
|
||||
"顶层必须只包含:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。",
|
||||
"不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。",
|
||||
"majorFactions 与 coreConflicts 必须是字符串数组。",
|
||||
"camp 必须是对象,且包含:name、description、dangerLevel。",
|
||||
"camp 必须是对象,且包含:name、description、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。",
|
||||
"原始文本:",
|
||||
response_text.trim(),
|
||||
].join("\n")
|
||||
@@ -751,7 +757,9 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景极简描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||||
" \"sceneTaskDescription\": \"首次进入该场景时要生成的章节任务核心上下文\",".to_string(),
|
||||
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
|
||||
" \"actEventDescriptions\": [\"第一幕事件描述\", \"第二幕事件描述\", \"第三幕事件描述\"],".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
@@ -761,10 +769,12 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
|
||||
"- sceneTaskDescription 必须描述玩家首次进入该场景时要完成的核心任务,会作为游戏章节任务生成上下文,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
|
||||
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景;玩家会在……”这类标题、摘要、规则句拼接格式;必须像可直接交给生图模型的自然画面描述。".to_string(),
|
||||
"- actEventDescriptions 必须恰好 3 条,分别描述每一幕发生的事件;事件必须和当前幕对面的角色强相关,控制在 24 到 56 个汉字内。".to_string(),
|
||||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||||
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
@@ -783,8 +793,8 @@ fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 补空数组,dangerLevel 补 medium。".to_string(),
|
||||
"每个地点只包含:name、description、visualDescription、sceneTaskDescription、actBackgroundPromptTexts、actEventDescriptions、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 和 actEventDescriptions 补空数组,dangerLevel 补 medium。".to_string(),
|
||||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
response_text.trim().to_string(),
|
||||
@@ -1061,13 +1071,14 @@ fn build_foundation_draft_profile_from_framework(
|
||||
)])
|
||||
}),
|
||||
);
|
||||
object.insert("camp".to_string(), framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" })));
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
|
||||
object.insert("camp".to_string(), camp.clone());
|
||||
object.insert(
|
||||
"playableNpcs".to_string(),
|
||||
JsonValue::Array(playable_detailed),
|
||||
);
|
||||
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
|
||||
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
|
||||
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(&camp, &landmarks);
|
||||
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
|
||||
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
|
||||
object.insert(
|
||||
@@ -1077,45 +1088,79 @@ fn build_foundation_draft_profile_from_framework(
|
||||
normalize_foundation_draft_profile(JsonValue::Object(object), session)
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprints_from_camp_and_landmarks(
|
||||
camp: &JsonValue,
|
||||
landmarks: &[JsonValue],
|
||||
) -> Vec<JsonValue> {
|
||||
let mut blueprints = Vec::with_capacity(landmarks.len() + 1);
|
||||
blueprints.push(build_scene_chapter_blueprint_from_scene(
|
||||
camp,
|
||||
0,
|
||||
"camp",
|
||||
"开局归处",
|
||||
));
|
||||
blueprints.extend(build_scene_chapter_blueprints_from_landmarks(landmarks));
|
||||
blueprints
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
|
||||
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
|
||||
landmarks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(chapter_index, landmark)| {
|
||||
let scene_name = json_text(landmark, "name")
|
||||
.unwrap_or_else(|| format!("关键场景{}", chapter_index + 1));
|
||||
let scene_id = json_text(landmark, "id")
|
||||
.unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1));
|
||||
let summary = json_text(landmark, "description").unwrap_or_default();
|
||||
let act_prompts =
|
||||
json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default();
|
||||
let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default();
|
||||
|
||||
json!({
|
||||
"id": scene_id.clone(),
|
||||
"sceneId": scene_id.clone(),
|
||||
"title": scene_name,
|
||||
"summary": summary,
|
||||
"linkedLandmarkIds": [scene_id.clone()],
|
||||
"acts": (0..3)
|
||||
.map(|act_index| build_scene_act_blueprint_from_landmark(
|
||||
&scene_id,
|
||||
&summary,
|
||||
&act_prompts,
|
||||
&scene_npc_names,
|
||||
act_index,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
build_scene_chapter_blueprint_from_scene(
|
||||
landmark,
|
||||
chapter_index,
|
||||
"saved-landmark",
|
||||
"关键场景",
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_scene_chapter_blueprint_from_scene(
|
||||
scene: &JsonValue,
|
||||
chapter_index: usize,
|
||||
id_prefix: &str,
|
||||
fallback_name_prefix: &str,
|
||||
) -> JsonValue {
|
||||
let scene_name = json_text(scene, "name")
|
||||
.unwrap_or_else(|| format!("{}{}", fallback_name_prefix, chapter_index + 1));
|
||||
let scene_id = json_text(scene, "id")
|
||||
.unwrap_or_else(|| format!("{}-{}", id_prefix, chapter_index + 1));
|
||||
let summary = json_text(scene, "description").unwrap_or_default();
|
||||
let scene_task_description = json_text(scene, "sceneTaskDescription")
|
||||
.unwrap_or_else(|| build_default_scene_task_description(&scene_name, &summary));
|
||||
let act_prompts = json_string_array(scene, "actBackgroundPromptTexts").unwrap_or_default();
|
||||
let act_events = json_string_array(scene, "actEventDescriptions").unwrap_or_default();
|
||||
let scene_npc_names = json_string_array(scene, "sceneNpcNames").unwrap_or_default();
|
||||
|
||||
json!({
|
||||
"id": scene_id.clone(),
|
||||
"sceneId": scene_id.clone(),
|
||||
"title": scene_name,
|
||||
"summary": summary,
|
||||
"sceneTaskDescription": scene_task_description,
|
||||
"linkedLandmarkIds": [scene_id.clone()],
|
||||
"acts": (0..3)
|
||||
.map(|act_index| build_scene_act_blueprint_from_landmark(
|
||||
&scene_id,
|
||||
&summary,
|
||||
&act_prompts,
|
||||
&act_events,
|
||||
&scene_npc_names,
|
||||
act_index,
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_scene_act_blueprint_from_landmark(
|
||||
scene_id: &str,
|
||||
scene_summary: &str,
|
||||
act_prompts: &[String],
|
||||
act_events: &[String],
|
||||
scene_npc_names: &[String],
|
||||
act_index: usize,
|
||||
) -> JsonValue {
|
||||
@@ -1130,6 +1175,16 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("");
|
||||
let opposite_npc_id = scene_npc_names.first().cloned().unwrap_or_default();
|
||||
let event_description = act_events
|
||||
.get(act_index)
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
build_default_act_event_description(scene_summary, opposite_npc_id.as_str(), act_index)
|
||||
});
|
||||
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
|
||||
json!({
|
||||
"id": format!("{}-act-{}", scene_id, act_index + 1),
|
||||
@@ -1138,9 +1193,42 @@ fn build_scene_act_blueprint_from_landmark(
|
||||
"summary": scene_summary,
|
||||
"backgroundPromptText": prompt,
|
||||
"encounterNpcIds": scene_npc_names,
|
||||
"primaryNpcId": opposite_npc_id,
|
||||
"oppositeNpcId": opposite_npc_id,
|
||||
"eventDescription": event_description,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_default_scene_task_description(scene_name: &str, scene_summary: &str) -> String {
|
||||
if scene_summary.trim().is_empty() {
|
||||
return format!("首次进入{scene_name}时,确认当前场景的核心异常、关键角色与下一步行动方向。");
|
||||
}
|
||||
format!("首次进入{scene_name}时,围绕{scene_summary}确认核心异常、关键角色与下一步行动方向。")
|
||||
}
|
||||
|
||||
fn build_default_act_event_description(
|
||||
scene_summary: &str,
|
||||
opposite_npc_id: &str,
|
||||
act_index: usize,
|
||||
) -> String {
|
||||
let role_text = if opposite_npc_id.trim().is_empty() {
|
||||
"当前场景关键角色"
|
||||
} else {
|
||||
opposite_npc_id.trim()
|
||||
};
|
||||
let scene_text = if scene_summary.trim().is_empty() {
|
||||
"场景内的主线压力"
|
||||
} else {
|
||||
scene_summary.trim()
|
||||
};
|
||||
format!(
|
||||
"第{}幕中,玩家与{}正面接触,围绕{}处理一件会改变局势走向的事件。",
|
||||
act_index + 1,
|
||||
role_text,
|
||||
scene_text,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
if !framework.is_object() {
|
||||
*framework = json!({});
|
||||
@@ -1183,6 +1271,83 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
if !object.get("camp").is_some_and(JsonValue::is_object) {
|
||||
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。", "dangerLevel": "low" }));
|
||||
}
|
||||
if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) {
|
||||
let camp_name = camp
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("开局归处")
|
||||
.to_string();
|
||||
let camp_description = camp
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("玩家进入世界后的第一处落脚点。")
|
||||
.to_string();
|
||||
camp.insert("name".to_string(), JsonValue::String(camp_name.clone()));
|
||||
camp.insert(
|
||||
"description".to_string(),
|
||||
JsonValue::String(camp_description.clone()),
|
||||
);
|
||||
if !camp
|
||||
.get("sceneTaskDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
camp.insert(
|
||||
"sceneTaskDescription".to_string(),
|
||||
JsonValue::String(build_default_scene_task_description(
|
||||
camp_name.as_str(),
|
||||
camp_description.as_str(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
if !camp
|
||||
.get("actBackgroundPromptTexts")
|
||||
.and_then(JsonValue::as_array)
|
||||
.is_some_and(|items| items.len() == 3)
|
||||
{
|
||||
// 中文注释:开局场景也必须进入逐幕生图队列;模型漏字段时用 camp 信息生成可用的三幕画面描述。
|
||||
camp.insert(
|
||||
"actBackgroundPromptTexts".to_string(),
|
||||
JsonValue::Array(
|
||||
(0..3)
|
||||
.map(|index| {
|
||||
JsonValue::String(format!(
|
||||
"{}第{}幕,{},画面保留玩家站位、近景可交互物件与远景世界压力。",
|
||||
camp_name,
|
||||
index + 1,
|
||||
camp_description,
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !camp
|
||||
.get("actEventDescriptions")
|
||||
.and_then(JsonValue::as_array)
|
||||
.is_some_and(|items| items.len() == 3)
|
||||
{
|
||||
camp.insert(
|
||||
"actEventDescriptions".to_string(),
|
||||
JsonValue::Array(
|
||||
(0..3)
|
||||
.map(|index| {
|
||||
JsonValue::String(build_default_act_event_description(
|
||||
camp_description.as_str(),
|
||||
"开局关键角色",
|
||||
index,
|
||||
))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_framework_summary_text(framework: &JsonValue, max_landmarks: usize) -> String {
|
||||
@@ -1516,6 +1681,25 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("第一幕");
|
||||
object.insert("title".to_string(), JsonValue::String(title.to_string()));
|
||||
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("summary".to_string(), JsonValue::String(summary.clone()));
|
||||
let scene_task_description = object
|
||||
.get("sceneTaskDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| build_default_scene_task_description(title, summary.as_str()));
|
||||
object.insert(
|
||||
"sceneTaskDescription".to_string(),
|
||||
JsonValue::String(scene_task_description),
|
||||
);
|
||||
let acts = object
|
||||
.get("acts")
|
||||
.and_then(JsonValue::as_array)
|
||||
@@ -1569,6 +1753,57 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
|
||||
"backgroundPromptText".to_string(),
|
||||
JsonValue::String(background_prompt),
|
||||
);
|
||||
let encounter_npc_ids = object
|
||||
.get("encounterNpcIds")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.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();
|
||||
let opposite_npc_id = object
|
||||
.get("oppositeNpcId")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
object
|
||||
.get("primaryNpcId")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
encounter_npc_ids
|
||||
.first()
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let event_description = object
|
||||
.get("eventDescription")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
build_default_act_event_description(summary.as_str(), opposite_npc_id.as_str(), index)
|
||||
});
|
||||
object.insert("encounterNpcIds".to_string(), JsonValue::Array(encounter_npc_ids));
|
||||
object.insert(
|
||||
"primaryNpcId".to_string(),
|
||||
JsonValue::String(opposite_npc_id.clone()),
|
||||
);
|
||||
object.insert("oppositeNpcId".to_string(), JsonValue::String(opposite_npc_id));
|
||||
object.insert(
|
||||
"eventDescription".to_string(),
|
||||
JsonValue::String(event_description),
|
||||
);
|
||||
JsonValue::Object(object)
|
||||
}
|
||||
|
||||
@@ -1577,6 +1812,7 @@ fn build_fallback_scene_chapter_blueprint() -> JsonValue {
|
||||
"id": "chapter-act-1",
|
||||
"title": "第一幕",
|
||||
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
|
||||
"sceneTaskDescription": "首次进入当前场景时,确认主线矛盾、关键角色与下一步追查方向。",
|
||||
"acts": [build_fallback_scene_act()],
|
||||
})
|
||||
}
|
||||
@@ -1591,6 +1827,10 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
|
||||
"title": if index == 0 { "开场场景幕".to_string() } else { format!("第{}幕", index + 1) },
|
||||
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
|
||||
"backgroundPromptText": "",
|
||||
"encounterNpcIds": [],
|
||||
"primaryNpcId": "",
|
||||
"oppositeNpcId": "",
|
||||
"eventDescription": build_default_act_event_description("玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。", "", index),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1769,6 +2009,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_chapter_blueprints_include_opening_camp_acts() {
|
||||
let mut framework = json!({
|
||||
"camp": {
|
||||
"name": "萧家祖宅",
|
||||
"description": "玩家开局并成长的家族祖宅。",
|
||||
"dangerLevel": "low"
|
||||
}
|
||||
});
|
||||
normalize_framework_shape(&mut framework, "废柴少年的逆袭传奇");
|
||||
let camp = framework
|
||||
.get("camp")
|
||||
.expect("camp should exist after normalize");
|
||||
let landmarks = vec![json!({
|
||||
"id": "landmark-duel-ground",
|
||||
"name": "萧家斗技场",
|
||||
"description": "萧家子弟修炼斗技、比试的场所。",
|
||||
"actBackgroundPromptTexts": ["斗技台晨雾未散,石阶旁少年们列队观望。", "木桩与兵器架围出训练区,族徽旗帜在风里猎猎。", "暮色压下斗技场,中央擂台留出一对一交锋空间。"]
|
||||
})];
|
||||
|
||||
let blueprints = build_scene_chapter_blueprints_from_camp_and_landmarks(camp, &landmarks);
|
||||
let opening_chapter = &blueprints[0];
|
||||
let opening_acts = opening_chapter
|
||||
.get("acts")
|
||||
.and_then(JsonValue::as_array)
|
||||
.expect("opening camp acts should exist");
|
||||
|
||||
assert_eq!(opening_chapter.get("sceneId"), Some(&json!("camp-1")));
|
||||
assert_eq!(opening_acts.len(), 3);
|
||||
assert!(opening_acts.iter().all(|act| act
|
||||
.get("backgroundPromptText")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())));
|
||||
assert_eq!(blueprints.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
|
||||
let act = normalize_scene_act_blueprint(
|
||||
@@ -1943,6 +2219,15 @@ mod tests {
|
||||
.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("sceneId"))
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("camp-1")
|
||||
);
|
||||
assert_eq!(
|
||||
draft_profile
|
||||
.get("sceneChapterBlueprints")
|
||||
@@ -1950,8 +2235,8 @@ mod tests {
|
||||
.and_then(|entries| entries.first())
|
||||
.and_then(|entry| entry.get("acts"))
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|entries| !entries.is_empty()),
|
||||
Some(true)
|
||||
.map(Vec::len),
|
||||
Some(3)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ pub async fn proxy_llm_chat_completions(
|
||||
.map(map_chat_message)
|
||||
.collect::<Vec<_>>(),
|
||||
max_tokens: None,
|
||||
enable_web_search: false,
|
||||
};
|
||||
|
||||
let response = llm_client
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(super) async fn generate_ai_story_text(
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(700);
|
||||
apply_rpg_web_search(state, &mut request);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
@@ -69,6 +70,7 @@ pub(super) async fn generate_action_story_payload(
|
||||
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
|
||||
return generate_npc_dialogue_payload(
|
||||
llm_client,
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
@@ -81,6 +83,7 @@ pub(super) async fn generate_action_story_payload(
|
||||
if should_generate_reasoned_combat_story(battle) {
|
||||
return generate_reasoned_story_payload(
|
||||
llm_client,
|
||||
state.config.rpg_llm_web_search_enabled,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
@@ -94,8 +97,13 @@ pub(super) async fn generate_action_story_payload(
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) {
|
||||
request.enable_web_search = state.config.rpg_llm_web_search_enabled;
|
||||
}
|
||||
|
||||
pub(super) async fn generate_npc_dialogue_payload(
|
||||
llm_client: &LlmClient,
|
||||
enable_web_search: bool,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
@@ -133,6 +141,7 @@ pub(super) async fn generate_npc_dialogue_payload(
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
llm_request.enable_web_search = enable_web_search;
|
||||
|
||||
let dialogue_text = llm_client
|
||||
.request_text(llm_request)
|
||||
@@ -154,6 +163,7 @@ pub(super) async fn generate_npc_dialogue_payload(
|
||||
|
||||
pub(super) async fn generate_reasoned_story_payload(
|
||||
llm_client: &LlmClient,
|
||||
enable_web_search: bool,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
@@ -184,6 +194,7 @@ pub(super) async fn generate_reasoned_story_payload(
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
llm_request.enable_web_search = enable_web_search;
|
||||
|
||||
let story_text = llm_client
|
||||
.request_text(llm_request)
|
||||
|
||||
@@ -1560,9 +1560,11 @@ mod tests {
|
||||
.expect("candidates should build");
|
||||
|
||||
assert_eq!(candidates.len(), 2);
|
||||
assert!(candidates[0]
|
||||
.image_src
|
||||
.starts_with("/generated-puzzle-assets/session-1/"));
|
||||
assert!(
|
||||
candidates[0]
|
||||
.image_src
|
||||
.starts_with("/generated-puzzle-assets/session-1/")
|
||||
);
|
||||
let legacy_public_prefix = ["generated-puzzle", "covers"].join("-");
|
||||
assert!(!candidates[0].image_src.contains(&legacy_public_prefix));
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub struct LlmTextRequest {
|
||||
pub model: Option<String>,
|
||||
pub messages: Vec<LlmMessage>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub enable_web_search: bool,
|
||||
}
|
||||
|
||||
// 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。
|
||||
@@ -122,8 +123,13 @@ struct ChatCompletionsRequestBody<'a> {
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
web_search_options: Option<ChatCompletionsWebSearchOptions>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatCompletionsWebSearchOptions {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LlmRawFailureInputLog<'a> {
|
||||
@@ -305,6 +311,7 @@ impl LlmTextRequest {
|
||||
model: None,
|
||||
messages,
|
||||
max_tokens: None,
|
||||
enable_web_search: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +332,11 @@ impl LlmTextRequest {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_web_search(mut self, enabled: bool) -> Self {
|
||||
self.enable_web_search = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), LlmError> {
|
||||
if self.messages.is_empty() {
|
||||
return Err(LlmError::InvalidRequest(
|
||||
@@ -651,6 +663,9 @@ impl LlmClient {
|
||||
messages: request.messages.as_slice(),
|
||||
stream,
|
||||
max_tokens: request.max_tokens,
|
||||
web_search_options: request
|
||||
.enable_web_search
|
||||
.then_some(ChatCompletionsWebSearchOptions {}),
|
||||
};
|
||||
let max_attempts = self.config.max_retries().saturating_add(1);
|
||||
|
||||
@@ -1228,6 +1243,47 @@ mod tests {
|
||||
assert_eq!(response.response_id.as_deref(), Some("resp_retry"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_text_sends_web_search_options_when_enabled() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let address = listener.local_addr().expect("listener should have addr");
|
||||
let server_handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("request should connect");
|
||||
let request_text = read_request(&mut stream);
|
||||
write_response(
|
||||
&mut stream,
|
||||
MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "application/json; charset=utf-8",
|
||||
body: r#"{"id":"resp_search","model":"test-model","choices":[{"message":{"content":"搜索成功"},"finish_reason":"stop"}]}"#.to_string(),
|
||||
extra_headers: Vec::new(),
|
||||
},
|
||||
);
|
||||
request_text
|
||||
});
|
||||
|
||||
let client = build_test_client(format!("http://{address}"), 0);
|
||||
let response = client
|
||||
.request_text(
|
||||
LlmTextRequest::single_turn("系统", "用户")
|
||||
.with_web_search(true)
|
||||
.with_max_tokens(128),
|
||||
)
|
||||
.await
|
||||
.expect("request_text should succeed");
|
||||
|
||||
let request_text = server_handle.join().expect("server thread should join");
|
||||
let request_body = request_text
|
||||
.split("\r\n\r\n")
|
||||
.nth(1)
|
||||
.expect("request body should exist");
|
||||
let request_json: serde_json::Value =
|
||||
serde_json::from_str(request_body).expect("request body should be json");
|
||||
|
||||
assert_eq!(response.content, "搜索成功");
|
||||
assert_eq!(request_json["web_search_options"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_text_accumulates_sse_response() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
@@ -1344,7 +1400,7 @@ mod tests {
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
fn read_request(stream: &mut std::net::TcpStream) {
|
||||
fn read_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(StdDuration::from_secs(1)))
|
||||
.expect("read timeout should be set");
|
||||
@@ -1381,6 +1437,8 @@ mod tests {
|
||||
Err(error) => panic!("mock server failed to read request: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(buffer.as_slice()).to_string()
|
||||
}
|
||||
|
||||
fn write_response(stream: &mut std::net::TcpStream, response: MockResponse) {
|
||||
|
||||
@@ -519,5 +519,4 @@ impl SpacetimeClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ pub use mapper::{
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord,
|
||||
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
|
||||
@@ -2733,12 +2733,22 @@ pub(crate) fn parse_rpg_agent_operation_type_record(
|
||||
"process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage),
|
||||
"draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation),
|
||||
"update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard),
|
||||
"sync_result_profile" => Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile),
|
||||
"generate_characters" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters),
|
||||
"generate_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks),
|
||||
"generate_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets),
|
||||
"sync_result_profile" => {
|
||||
Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile)
|
||||
}
|
||||
"generate_characters" => {
|
||||
Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters)
|
||||
}
|
||||
"generate_landmarks" => {
|
||||
Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks)
|
||||
}
|
||||
"generate_role_assets" => {
|
||||
Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets)
|
||||
}
|
||||
"sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets),
|
||||
"generate_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets),
|
||||
"generate_scene_assets" => {
|
||||
Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets)
|
||||
}
|
||||
"sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets),
|
||||
"expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail),
|
||||
"publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld),
|
||||
|
||||
@@ -3771,15 +3771,24 @@ fn apply_scene_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, sc
|
||||
}
|
||||
|
||||
fn update_scene_chapter_acts_for_scene(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, image_src: &str, generated_scene_asset_id: &str) {
|
||||
let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; };
|
||||
for chapter in chapters {
|
||||
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
|
||||
if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; }
|
||||
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
|
||||
for act in acts {
|
||||
if let Some(act_object) = act.as_object_mut() {
|
||||
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
|
||||
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
|
||||
// 中文注释:当前结果页与发布链路以 sceneChapterBlueprints 为主,旧 sceneChapters 仅作兼容;同步场景资产时两边都要写,避免开局场景幕图只落在旧字段。
|
||||
for field in ["sceneChapterBlueprints", "sceneChapters"] {
|
||||
let Some(chapters) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; };
|
||||
for chapter in chapters {
|
||||
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
|
||||
let is_target_scene = read_optional_text_field(chapter_object, &["sceneId"]).as_deref() == Some(scene_id)
|
||||
|| chapter_object
|
||||
.get("linkedLandmarkIds")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|ids| ids.iter().any(|id| id.as_str().map(str::trim) == Some(scene_id)))
|
||||
.unwrap_or(false);
|
||||
if !is_target_scene { continue; }
|
||||
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
|
||||
for act in acts {
|
||||
if let Some(act_object) = act.as_object_mut() {
|
||||
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
|
||||
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user