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 { 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( llm_client: &LlmClient, user_prompt: String, debug_label: &str, repair_prompt_builder: F, repair_debug_label: &str, empty_response_message: &str, ) -> Result 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, 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 = 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, 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 = 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, 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, 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 { 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::(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::>() .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::>().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::>().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::>().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::>().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::>().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::>().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::>().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::>().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, story_detailed: Vec, landmarks: Vec, session: &CustomWorldAgentSessionRecord, setting_text: &str, ) -> JsonMap { 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::>() .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::>() .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::>() .join("\n") }) .collect::>() .join("\n") } fn landmark_names_for_role(framework: &JsonValue, role_name: &str) -> Vec { 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 { value .get(key) .and_then(JsonValue::as_array) .cloned() .unwrap_or_default() } fn names_from_entries(entries: &[JsonValue]) -> Vec { 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 { 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 { json_path_text(value, &[key]) } fn json_path_text(value: &JsonValue, path: &[&str]) -> Option { 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> { 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::>(); 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 { 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, 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, 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::>() }) .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, 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) { 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::>(); 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, 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, 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, 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::>() .join(";") }) .filter(|value| !value.trim().is_empty()) }) .unwrap_or_else(|| { "玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。" .to_string() }) } fn read_text_field(object: &JsonMap, keys: &[&str]) -> Option { 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 { let trimmed = text.trim(); if let Some(start) = trimmed.find('{') && let Some(end) = trimmed.rfind('}') && end > start { return serde_json::from_str::(&trimmed[start..=end]); } serde_json::from_str::(trimmed) } fn 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::(&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::(&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>>, response_bodies: Vec, ) -> 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 { buffer .windows(4) .position(|window| window == b"\r\n\r\n") .map(|index| index + 4) } fn read_content_length(headers: &[u8]) -> Option { 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::().ok(); } None }) } }