use module_custom_world::{ empty_agent_anchor_content_json, empty_agent_asset_coverage_json, empty_agent_creator_intent_readiness_json, empty_json_array, empty_json_object, }; use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; use spacetime_client::{ CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, CustomWorldAgentSessionRecord, }; #[derive(Clone, Debug)] pub(crate) struct CustomWorldAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a CustomWorldAgentSessionRecord, pub quick_fill_requested: bool, pub focus_card_id: Option, } #[derive(Clone, Debug)] pub(crate) struct CustomWorldAgentTurnResult { pub assistant_reply_text: String, pub phase_label: String, pub phase_detail: String, pub operation_status: String, pub operation_progress: u32, pub stage: String, pub progress_percent: u32, pub focus_card_id: Option, pub anchor_content_json: String, pub creator_intent_json: Option, pub creator_intent_readiness_json: String, pub anchor_pack_json: Option, pub draft_profile_json: Option, pub pending_clarifications_json: String, pub suggested_actions_json: String, pub recommended_replies_json: String, pub quality_findings_json: String, pub asset_coverage_json: String, pub error_message: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PromptUserInputSignal { Rich, Normal, Sparse, Correction, Delegate, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PromptDriftRisk { Low, Medium, High, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PromptConversationMode { Bootstrap, Expand, Compress, RepairDirection, ForceComplete, Closing, } #[derive(Clone, Debug)] #[allow(dead_code)] struct PromptDynamicState { current_turn: u32, progress_percent: u32, user_input_signal: PromptUserInputSignal, drift_risk: PromptDriftRisk, quick_fill_requested: bool, conversation_mode: PromptConversationMode, judgement_summary: String, } #[derive(Clone, Debug, Default)] struct PromptDynamicStateInference { user_input_signal: Option, drift_risk: Option, conversation_mode: Option, judgement_summary: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct WorldPromiseValue { #[serde(default)] hook: String, #[serde(default)] differentiator: String, #[serde(default)] desired_experience: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PlayerFantasyValue { #[serde(default)] player_role: String, #[serde(default)] core_pursuit: String, #[serde(default)] fear_of_loss: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct ThemeBoundaryValue { #[serde(default)] tone_keywords: Vec, #[serde(default)] aesthetic_directives: Vec, #[serde(default)] forbidden_directives: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PlayerEntryPointValue { #[serde(default)] opening_identity: String, #[serde(default)] opening_problem: String, #[serde(default)] entry_motivation: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CoreConflictValue { #[serde(default)] surface_conflicts: Vec, #[serde(default)] hidden_crisis: String, #[serde(default)] first_touched_conflict: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct KeyRelationshipValue { #[serde(default)] pairs: String, #[serde(default)] relationship_type: String, #[serde(default)] secret_or_cost: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct HiddenLineValue { #[serde(default)] hidden_truths: Vec, #[serde(default)] misdirection_hints: Vec, #[serde(default)] reveal_pacing: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct IconicElementValue { #[serde(default)] iconic_motifs: Vec, #[serde(default)] institutions_or_artifacts: Vec, #[serde(default)] hard_rules: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct EightAnchorContent { #[serde(default)] world_promise: Option, #[serde(default)] player_fantasy: Option, #[serde(default)] theme_boundary: Option, #[serde(default)] player_entry_point: Option, #[serde(default)] core_conflict: Option, #[serde(default)] key_relationships: Vec, #[serde(default)] hidden_lines: Option, #[serde(default)] iconic_elements: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorCharacterSeedRecord { id: String, name: String, role: String, public_mask: String, hidden_hook: String, relation_to_player: String, notes: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorLandmarkSeedRecord { id: String, name: String, purpose: String, mood: String, secret: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorIntentRecord { source_mode: String, raw_setting_text: String, world_hook: String, theme_keywords: Vec, tone_directives: Vec, player_premise: String, opening_situation: String, core_conflicts: Vec, key_characters: Vec, key_landmarks: Vec, iconic_elements: Vec, forbidden_directives: Vec, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CreatorIntentReadiness { is_ready: bool, completed_keys: Vec, missing_keys: Vec, } #[derive(Clone, Debug)] struct SingleTurnModelOutput { next_anchor_content: EightAnchorContent, progress_percent: u32, reply_text: String, } #[derive(Clone, Debug)] pub(crate) struct CustomWorldTurnError { message: String, } impl CustomWorldTurnError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for CustomWorldTurnError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.message) } } impl std::error::Error for CustomWorldTurnError {} const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。 你正在和用户一起共创一个游戏世界。每一轮你都必须读取: 1. 当前完整设定结构 2. 用户聊天记录 然后输出: 1. 一版新的完整设定结构 2. 当前 progress 百分比 3. 一段直接回复用户的话 你必须把“新的完整设定结构”视为下一轮的唯一有效版本。 你的输出会直接覆盖上一版设定结构。 你不是在做局部 patch。 你不是在做解释报告。 你不是在给开发者写分析。 你是在同时完成: 1. 世界设定更新 2. 当前推进程度判断 3. 对用户的共创回复"#; const GLOBAL_HARD_RULES: &str = r#"全局硬约束: 1. 必须输出完整的设定结构,而不是只输出变化部分。 2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。 3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。 4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。 5. progressPercent 最低为 0,不允许为负数。 6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。 7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。 8. replyText 不要写成长篇策划文,不要展开大段世界观百科。 9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。 10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。 11. 你输出的 JSON 必须可以被直接解析。 12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#; const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。 这表示用户接受你基于当前方向自动补完剩余设定。 本轮要求: 1. 不要再继续提问 2. 直接输出一版尽量完整的设定结构 3. progressPercent 直接输出为 100 4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#; const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。 你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。 你必须综合以下信息判断: 1. 当前轮次 currentTurn 2. 当前完成度 progressPercent 3. 用户是否要求自动补全 quickFillRequested 4. 当前完整设定结构 5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息 你需要输出 4 个字段: 1. userInputSignal:只能是 rich / normal / sparse / correction / delegate 2. driftRisk:只能是 low / medium / high 3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing 4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么 请按下面的语义判断。 一、userInputSignal 定义 1. rich - 用户这一轮给了多条可直接落地的有效信息 - 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个 - 正式生成时应优先高密度吸收,不要只更新一个点 2. normal - 用户在顺着当前方向做正常补充 - 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统 - 正式生成时应稳定推进并自然接住用户内容 3. sparse - 用户输入很短、很虚、很笼统,或几乎没有新增有效事实 - 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达 - 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问 - 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题 4. correction - 用户这轮核心动作是在修正、替换、推翻、重定向旧设定 - 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction - correction 的优先级高于 rich 和 normal 5. delegate - 用户把部分决定权交给系统 - 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案” - delegate 关注的是授权关系,不只是信息多寡 二、driftRisk 定义 1. low - 当前轮输入与已有方向基本一致 - 没有明显改口或冲突 2. medium - 当前轮带来一定方向变化或扩张 - 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散 3. high - 用户明确纠偏、改口、替换方向,或最近多轮反复修正 - 这时最重要的是防止旧方向重新回流到正式生成结果里 三、conversationMode 选择原则 1. bootstrap - 适用于前期、信息少、核心方向未稳定 - replyText 更适合低压力确认和单点启发 2. expand - 适用于方向已成形,正在顺着现有路线继续补充 - replyText 更适合总结已接住的内容并往前推一步 3. compress - 适用于中后段,已有骨架,需要开始收束 - replyText 更适合聚焦最关键缺口,而不是继续开支线 4. repair_direction - 适用于用户正在纠偏 - replyText 更适合先承认修正,再沿修正后的方向继续推进 5. force_complete - 适用于用户明确要求自动补全 - replyText 不再提问,而应给出完成感和下一步引导 6. closing - 适用于接近完成但并非强制一键补全 - replyText 更像确认与收束,而不是前期式探索 四、优先级规则 1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete 2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction 3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate 4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择 五、关于 replyText 风格的专门判断要求 1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问 2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多 3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈 4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进 5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法 六、关于 replyText 用语的硬约束 1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词 2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点 3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户 4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构 5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语 七、关于 judgementSummary 的写法 1. 必须简洁,不要写成长篇分析 2. 必须直接服务于下一轮正式生成 3. 最好同时包含两层信息: - 为什么这么判断 - 正式生成时最该优先做什么,或最该避免什么 八、硬性约束 1. 只能输出 JSON,不能输出解释、代码块或额外说明 2. 不能发明上下文里不存在的设定事实 3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定” 4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态 5. judgementSummary 必须是中文 6. 输出值必须严格落在给定枚举中"#; const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: { "userInputSignal": "normal", "driftRisk": "low", "conversationMode": "expand", "judgementSummary": "" }"#; const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字: { "replyText": "", "progressPercent": 0, "nextAnchorContent": { "worldPromise": { "hook": "", "differentiator": "", "desiredExperience": "" }, "playerFantasy": { "playerRole": "", "corePursuit": "", "fearOfLoss": "" }, "themeBoundary": { "toneKeywords": [], "aestheticDirectives": [], "forbiddenDirectives": [] }, "playerEntryPoint": { "openingIdentity": "", "openingProblem": "", "entryMotivation": "" }, "coreConflict": { "surfaceConflicts": [], "hiddenCrisis": "", "firstTouchedConflict": "" }, "keyRelationships": [ { "pairs": "", "relationshipType": "", "secretOrCost": "" } ], "hiddenLines": { "hiddenTruths": [], "misdirectionHints": [], "revealPacing": "" }, "iconicElements": { "iconicMotifs": [], "institutionsOrArtifacts": [], "hardRules": [] } } }"#; pub(crate) async fn run_custom_world_agent_turn( request: CustomWorldAgentTurnRequest<'_>, on_reply_update: F, ) -> Result where F: FnMut(&str), { let current_anchor_content = normalize_eight_anchor_content(&request.session.anchor_content); let should_preserve_draft_stage = matches!( request.session.stage.as_str(), "object_refining" | "visual_refining" ) && !request.session.draft_cards.is_empty(); let assistant_turn = stream_single_turn( request.llm_client, request.session.messages.as_slice(), request.session.current_turn.saturating_add(1), request.session.progress_percent, request.quick_fill_requested, ¤t_anchor_content, on_reply_update, ) .await?; let next_anchor_content = assistant_turn.next_anchor_content.clone(); let next_creator_intent = build_creator_intent_from_eight_anchor_content(&next_anchor_content); let progress_percent = assistant_turn.progress_percent; let creator_intent_readiness = if progress_percent >= 100 { CreatorIntentReadiness { is_ready: true, completed_keys: vec![ "world_hook".to_string(), "player_premise".to_string(), "theme_and_tone".to_string(), "core_conflict".to_string(), "relationship_seed".to_string(), "iconic_element".to_string(), ], missing_keys: Vec::new(), } } else { evaluate_creator_intent_readiness(&next_creator_intent) }; let derived_stage = resolve_creator_intent_stage(true, &creator_intent_readiness); let should_stay_in_draft_stage = should_preserve_draft_stage && progress_percent >= 100; let next_stage = if should_stay_in_draft_stage { if request.session.stage == "visual_refining" { "visual_refining".to_string() } else { "object_refining".to_string() } } else { derived_stage.to_string() }; let anchor_content_json = serialize_json( &serde_json::to_value(&next_anchor_content).unwrap_or_else(|_| json!({})), &empty_agent_anchor_content_json(), ); let creator_intent_json = Some(serialize_json( &serde_json::to_value(&next_creator_intent).unwrap_or_else(|_| json!({})), &empty_json_object(), )); let creator_intent_readiness_json = serialize_json( &serde_json::to_value(&creator_intent_readiness).unwrap_or_else(|_| json!({})), &empty_agent_creator_intent_readiness_json(), ); let anchor_pack_json = Some(serialize_json( &build_anchor_pack_from_eight_anchor_content(&next_anchor_content, progress_percent), &empty_json_object(), )); let pending_clarifications_json = if should_stay_in_draft_stage || progress_percent >= 100 { empty_json_array() } else { serialize_json( &JsonValue::Array(build_pending_clarifications( &next_creator_intent, &creator_intent_readiness, )), &empty_json_array(), ) }; let suggested_actions_json = if should_stay_in_draft_stage { serialize_json( &JsonValue::Array( request .session .suggested_actions .iter() .cloned() .collect::>(), ), &empty_json_array(), ) } else if progress_percent >= 100 { r#"[{"id":"draft_foundation","type":"draft_foundation","label":"生成游戏设定草稿"}]"# .to_string() } else { empty_json_array() }; let recommended_replies_json = empty_json_array(); let quality_findings_json = if should_stay_in_draft_stage { serialize_json( &JsonValue::Array( request .session .quality_findings .iter() .cloned() .collect::>(), ), &empty_json_array(), ) } else { empty_json_array() }; let asset_coverage_json = if should_stay_in_draft_stage { serialize_json( &request.session.asset_coverage, &empty_agent_asset_coverage_json(), ) } else { empty_agent_asset_coverage_json() }; let draft_profile_json = if should_stay_in_draft_stage { Some(serialize_json( &request.session.draft_profile, &empty_json_object(), )) } else { Some(serialize_json( &build_minimal_draft_profile_from_intent(&next_creator_intent), &empty_json_object(), )) }; Ok(CustomWorldAgentTurnResult { assistant_reply_text: assistant_turn.reply_text, phase_label: "消息已处理".to_string(), phase_detail: "本轮回复已由大模型生成并回写会话。".to_string(), operation_status: "completed".to_string(), operation_progress: 100, stage: next_stage, progress_percent, focus_card_id: if should_stay_in_draft_stage { request.session.focus_card_id.clone() } else { request.focus_card_id }, anchor_content_json, creator_intent_json, creator_intent_readiness_json, anchor_pack_json, draft_profile_json, pending_clarifications_json, suggested_actions_json, recommended_replies_json, quality_findings_json, asset_coverage_json, error_message: None, }) } pub(crate) fn build_finalize_record_input( session_id: String, owner_user_id: String, operation_id: String, assistant_message_id: String, result: CustomWorldAgentTurnResult, updated_at_micros: i64, ) -> CustomWorldAgentMessageFinalizeRecordInput { CustomWorldAgentMessageFinalizeRecordInput { session_id, owner_user_id, operation_id, assistant_message_id: Some(assistant_message_id), assistant_reply_text: Some(result.assistant_reply_text), phase_label: result.phase_label, phase_detail: result.phase_detail, operation_status: result.operation_status, operation_progress: result.operation_progress, stage: result.stage, progress_percent: result.progress_percent, focus_card_id: result.focus_card_id, anchor_content_json: result.anchor_content_json, creator_intent_json: result.creator_intent_json, creator_intent_readiness_json: result.creator_intent_readiness_json, anchor_pack_json: result.anchor_pack_json, draft_profile_json: result.draft_profile_json, pending_clarifications_json: result.pending_clarifications_json, suggested_actions_json: result.suggested_actions_json, recommended_replies_json: result.recommended_replies_json, quality_findings_json: result.quality_findings_json, asset_coverage_json: result.asset_coverage_json, error_message: result.error_message, updated_at_micros, } } fn serialize_optional_json_object(value: &JsonValue) -> Option { if value.is_null() { None } else { Some(serialize_json(value, &empty_json_object())) } } fn serialize_string_array(values: &[String]) -> String { serialize_json( &JsonValue::Array( values .iter() .cloned() .map(JsonValue::String) .collect::>(), ), &empty_json_array(), ) } pub(crate) fn build_failed_finalize_record_input( session_id: String, owner_user_id: String, operation_id: String, session: &CustomWorldAgentSessionRecord, error_message: String, updated_at_micros: i64, ) -> CustomWorldAgentMessageFinalizeRecordInput { CustomWorldAgentMessageFinalizeRecordInput { session_id, owner_user_id, operation_id, assistant_message_id: None, assistant_reply_text: None, phase_label: "消息处理失败".to_string(), phase_detail: error_message.clone(), operation_status: "failed".to_string(), operation_progress: 100, stage: session.stage.clone(), progress_percent: session.progress_percent, focus_card_id: session.focus_card_id.clone(), anchor_content_json: serialize_json( &session.anchor_content, &empty_agent_anchor_content_json(), ), creator_intent_json: serialize_optional_json_object(&session.creator_intent), creator_intent_readiness_json: serialize_json( &session.creator_intent_readiness, &empty_agent_creator_intent_readiness_json(), ), anchor_pack_json: serialize_optional_json_object(&session.anchor_pack), draft_profile_json: serialize_optional_json_object(&session.draft_profile), pending_clarifications_json: serialize_json( &JsonValue::Array(session.pending_clarifications.clone()), &empty_json_array(), ), suggested_actions_json: serialize_json( &JsonValue::Array(session.suggested_actions.clone()), &empty_json_array(), ), recommended_replies_json: serialize_string_array(&session.recommended_replies), quality_findings_json: serialize_json( &JsonValue::Array(session.quality_findings.clone()), &empty_json_array(), ), asset_coverage_json: serialize_json( &session.asset_coverage, &empty_agent_asset_coverage_json(), ), error_message: Some(error_message), updated_at_micros, } } async fn stream_single_turn( llm_client: Option<&LlmClient>, messages: &[CustomWorldAgentMessageRecord], current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, mut on_reply_update: F, ) -> Result where F: FnMut(&str), { let llm_client = llm_client.ok_or_else(|| CustomWorldTurnError::new("当前模型不可用,请稍后重试。"))?; let chat_history = build_chat_history(messages); let dynamic_state = resolve_dynamic_state( llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history, ) .await; let prompt = build_eight_anchor_single_turn_prompt( current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history, &dynamic_state, ); let mut latest_reply_text = String::new(); let response = llm_client .stream_text( LlmTextRequest::new(vec![ LlmMessage::system(prompt), LlmMessage::user("请按约定输出这一轮的 JSON。"), ]), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) && reply_progress != latest_reply_text { latest_reply_text = reply_progress.clone(); on_reply_update(reply_progress.as_str()); } }, ) .await; let response = response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?; let parsed = parse_json_response_text(response.content.as_str()) .map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?; let next_anchor_content = normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null)); let progress_percent = if quick_fill_requested { 100 } else { clamp_progress_percent(parsed.get("progressPercent")) }; let reply_text = to_text(parsed.get("replyText")) .ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?; if reply_text != latest_reply_text { on_reply_update(reply_text.as_str()); } Ok(SingleTurnModelOutput { next_anchor_content, progress_percent, reply_text, }) } async fn resolve_dynamic_state( llm_client: &LlmClient, current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> PromptDynamicState { let fallback = build_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, None, ); let (system_prompt, user_prompt) = build_prompt_dynamic_state_inference_prompt( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, ); let response = llm_client .request_text(LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ])) .await; let Ok(response) = response else { return fallback; }; let Ok(parsed) = parse_json_response_text(response.content.as_str()) else { return fallback; }; build_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, Some(PromptDynamicStateInference { user_input_signal: parse_user_input_signal(parsed.get("userInputSignal")), drift_risk: parse_drift_risk(parsed.get("driftRisk")), conversation_mode: parse_conversation_mode(parsed.get("conversationMode")), judgement_summary: to_text(parsed.get("judgementSummary")), }), ) } fn build_prompt_dynamic_state( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], inference: Option, ) -> PromptDynamicState { let fallback = build_rule_based_prompt_dynamic_state( current_turn, progress_percent, quick_fill_requested, current_anchor_content, chat_history, ); let Some(inference) = inference else { return fallback; }; let user_input_signal = inference .user_input_signal .unwrap_or(fallback.user_input_signal); let drift_risk = inference.drift_risk.unwrap_or(fallback.drift_risk); let conversation_mode = inference .conversation_mode .unwrap_or(fallback.conversation_mode); let judgement_summary = inference .judgement_summary .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode) }); PromptDynamicState { current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, conversation_mode, judgement_summary, } } fn build_rule_based_prompt_dynamic_state( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> PromptDynamicState { let user_input_signal = detect_user_input_signal(chat_history); let drift_risk = detect_drift_risk(chat_history, current_anchor_content, progress_percent); let conversation_mode = pick_conversation_mode( current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, ); PromptDynamicState { current_turn, progress_percent, user_input_signal, drift_risk, quick_fill_requested, conversation_mode, judgement_summary: summarize_dynamic_state( user_input_signal, drift_risk, conversation_mode, ), } } fn build_prompt_dynamic_state_inference_prompt( current_turn: u32, progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], ) -> (String, String) { ( [ STATE_INFERENCE_SYSTEM_PROMPT, STATE_INFERENCE_OUTPUT_CONTRACT, ] .join("\n\n"), [ format!("当前轮次:{current_turn}"), format!("当前完成度:{progress_percent}"), format!( "是否要求自动补全:{}", if quick_fill_requested { "是" } else { "否" } ), render_current_anchor_context(current_anchor_content), render_chat_history_context(chat_history), ] .join("\n\n"), ) } fn build_eight_anchor_single_turn_prompt( _current_turn: u32, _progress_percent: u32, quick_fill_requested: bool, current_anchor_content: &EightAnchorContent, chat_history: &[JsonValue], dynamic_state: &PromptDynamicState, ) -> String { let mut blocks = vec![ BASE_SYSTEM_PROMPT.to_string(), GLOBAL_HARD_RULES.to_string(), mode_rules(dynamic_state.conversation_mode).to_string(), user_signal_rules(dynamic_state.user_input_signal).to_string(), ]; if quick_fill_requested { blocks.push(QUICK_FILL_EXTRA_RULES.to_string()); } blocks.push(render_dynamic_state_context(dynamic_state)); blocks.push(render_current_anchor_context(current_anchor_content)); blocks.push(render_chat_history_context(chat_history)); blocks.push(OUTPUT_CONTRACT_REMINDER.to_string()); blocks.join("\n\n") } fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec { messages .iter() .filter(|message| { (message.role == "user" || message.role == "assistant") && !message.text.trim().is_empty() }) .map(|message| { json!({ "role": message.role, "content": message.text, }) }) .collect() } fn normalize_eight_anchor_content(value: &JsonValue) -> EightAnchorContent { serde_json::from_value::(value.clone()).unwrap_or_default() } fn build_creator_intent_from_eight_anchor_content( anchor_content: &EightAnchorContent, ) -> CreatorIntentRecord { let key_characters = anchor_content .key_relationships .iter() .enumerate() .map(|(index, entry)| { let (lead_name, relation_to_player) = split_relationship_pair(entry.pairs.as_str()); CreatorCharacterSeedRecord { id: format!("creator-character-{}", index + 1), name: if lead_name.is_empty() { format!("关键人物{}", index + 1) } else { lead_name }, role: entry.relationship_type.clone(), public_mask: String::new(), hidden_hook: entry.secret_or_cost.clone(), relation_to_player, notes: String::new(), } }) .collect::>(); let core_conflicts = anchor_content .core_conflict .as_ref() .map(|value| { value .surface_conflicts .iter() .cloned() .chain( (!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()), ) .collect::>() }) .unwrap_or_default(); CreatorIntentRecord { source_mode: "freeform".to_string(), raw_setting_text: compact_lines([ anchor_content .world_promise .as_ref() .map(|value| value.differentiator.as_str()), anchor_content .player_fantasy .as_ref() .map(|value| value.core_pursuit.as_str()), anchor_content .hidden_lines .as_ref() .and_then(|value| value.hidden_truths.first().map(String::as_str)), ]), world_hook: compact_lines([ anchor_content .world_promise .as_ref() .map(|value| value.hook.as_str()), anchor_content .world_promise .as_ref() .map(|value| value.differentiator.as_str()), ]), theme_keywords: anchor_content .theme_boundary .as_ref() .map(|value| value.tone_keywords.clone()) .unwrap_or_default(), tone_directives: anchor_content .theme_boundary .as_ref() .map(|value| value.aesthetic_directives.clone()) .unwrap_or_default(), player_premise: compact_lines([ anchor_content .player_fantasy .as_ref() .map(|value| value.player_role.as_str()), anchor_content .player_entry_point .as_ref() .map(|value| value.opening_identity.as_str()), ]), opening_situation: compact_lines([ anchor_content .player_entry_point .as_ref() .map(|value| value.opening_problem.as_str()), anchor_content .player_entry_point .as_ref() .map(|value| value.entry_motivation.as_str()), ]), core_conflicts: dedupe_string_list(core_conflicts, 6), key_characters, key_landmarks: Vec::new(), iconic_elements: dedupe_string_list( anchor_content .iconic_elements .as_ref() .map(|value| { value .iconic_motifs .iter() .cloned() .chain(value.institutions_or_artifacts.iter().cloned()) .collect::>() }) .unwrap_or_default(), 8, ), forbidden_directives: dedupe_string_list( anchor_content .theme_boundary .as_ref() .map(|value| value.forbidden_directives.clone()) .unwrap_or_default() .into_iter() .chain( anchor_content .iconic_elements .as_ref() .map(|value| value.hard_rules.clone()) .unwrap_or_default(), ) .collect::>(), 8, ), } } fn evaluate_creator_intent_readiness(intent: &CreatorIntentRecord) -> CreatorIntentReadiness { let relationship_ready = intent.key_characters.iter().any(|entry| { !entry.name.trim().is_empty() && (!entry.relation_to_player.trim().is_empty() || !entry.hidden_hook.trim().is_empty()) }); let checks = [ ( "world_hook", intent.world_hook.trim().chars().count() >= 8 || intent.raw_setting_text.trim().chars().count() >= 24, ), ( "player_premise", !intent.player_premise.trim().is_empty() && !intent.opening_situation.trim().is_empty(), ), ( "theme_and_tone", !intent.theme_keywords.is_empty() && !intent.tone_directives.is_empty(), ), ("core_conflict", !intent.core_conflicts.is_empty()), ( "relationship_seed", !intent.key_characters.is_empty() && relationship_ready, ), ("iconic_element", !intent.iconic_elements.is_empty()), ]; let mut completed_keys = Vec::new(); let mut missing_keys = Vec::new(); for (key, ready) in checks { if ready { completed_keys.push(key.to_string()); } else { missing_keys.push(key.to_string()); } } CreatorIntentReadiness { is_ready: missing_keys.is_empty(), completed_keys, missing_keys, } } fn resolve_creator_intent_stage( has_user_input: bool, readiness: &CreatorIntentReadiness, ) -> &'static str { if readiness.is_ready { "foundation_review" } else if has_user_input { "clarifying" } else { "collecting_intent" } } fn build_pending_clarifications( _intent: &CreatorIntentRecord, readiness: &CreatorIntentReadiness, ) -> Vec { let definitions = [ ( "world_hook", 1, "世界一句话", "先用一句话收住这个世界最独特的核心幻想,我会据此继续往下补。", ), ( "player_premise", 2, "玩家身份与开局", "玩家是谁,故事开场时卡在什么处境里?你可以把身份和开局困境一起告诉我。", ), ( "core_conflict", 3, "核心冲突", "现在推动这个世界往前走的主要冲突是什么?最好是能立刻形成剧情压力的那种。", ), ( "theme_and_tone", 4, "主题气质", "它整体更偏什么主题和气质?比如冷峻、压迫、浪漫、潮湿,也可以顺手告诉我不要什么。", ), ( "relationship_seed", 5, "关键关系钩子", "给我一个关键人物种子就行,他和玩家是什么关系,或者他藏着什么暗线?", ), ( "iconic_element", 6, "标志性要素", "这个世界至少给我 1 个一眼能认出来的标志性元素、机制或意象。", ), ]; definitions .iter() .filter(|(target_key, _, _, _)| readiness.missing_keys.iter().any(|key| key == target_key)) .take(1) .map(|(target_key, priority, label, question)| { json!({ "id": target_key, "label": label, "question": question, "targetKey": target_key, "priority": priority, }) }) .collect() } fn build_anchor_pack_from_eight_anchor_content( anchor_content: &EightAnchorContent, progress_percent: u32, ) -> JsonValue { let intent = build_creator_intent_from_eight_anchor_content(anchor_content); let completed_keys = if progress_percent >= 100 { vec!["eight_anchor_minimum_loop".to_string()] } else { Vec::new() }; let missing_keys = if progress_percent >= 100 { Vec::new() } else { vec!["eight_anchor_minimum_loop".to_string()] }; json!({ "worldSummary": clamp_text( if !intent.world_hook.trim().is_empty() { intent.world_hook.as_str() } else { intent.raw_setting_text.as_str() }, 96, ), "creatorIntentSummary": clamp_text(build_draft_summary_from_intent(&intent).as_str(), 180), "completedKeys": completed_keys, "missingKeys": missing_keys, "keyCharacterAnchors": intent .key_characters .iter() .map(|entry| { json!({ "id": entry.id, "name": if entry.name.trim().is_empty() { "未命名关键人物" } else { entry.name.as_str() }, "summary": clamp_text( compact_lines([ Some(entry.role.as_str()), Some(entry.relation_to_player.as_str()), Some(entry.hidden_hook.as_str()), ]) .as_str(), 60, ), }) }) .collect::>(), "motifDirectives": dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .chain(intent.iconic_elements.iter().cloned()) .collect::>(), 12, ), }) } fn build_minimal_draft_profile_from_intent(intent: &CreatorIntentRecord) -> JsonValue { let title = build_draft_title_from_intent(intent); let summary = build_draft_summary_from_intent(intent); let subtitle = clamp_text( format!( "{} · {}", if !intent.player_premise.trim().is_empty() { intent.player_premise.as_str() } else { "玩家入口待继续细化" }, intent .core_conflicts .first() .map(String::as_str) .unwrap_or("核心冲突仍在整理") ) .as_str(), 40, ); let tone = clamp_text( dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .collect::>(), 8, ) .join("、") .as_str(), 60, ); let playable_npcs = intent .key_characters .iter() .map(|entry| { json!({ "id": entry.id, "name": entry.name, "title": entry.role, "role": entry.role, "publicMask": entry.public_mask, "publicIdentity": entry.public_mask, "hiddenHook": entry.hidden_hook, "currentPressure": entry.hidden_hook, "relationToPlayer": entry.relation_to_player, "summary": clamp_text( compact_lines([ Some(entry.role.as_str()), Some(entry.relation_to_player.as_str()), Some(entry.hidden_hook.as_str()), ]) .as_str(), 120, ), "threadIds": [], "skills": [], }) }) .collect::>(); let landmarks = intent .key_landmarks .iter() .map(|entry| { json!({ "id": entry.id, "name": entry.name, "purpose": entry.purpose, "mood": entry.mood, "secret": entry.secret, "summary": clamp_text( compact_lines([ Some(entry.purpose.as_str()), Some(entry.mood.as_str()), Some(entry.secret.as_str()), ]) .as_str(), 120, ), }) }) .collect::>(); json!({ "name": title, "title": title, "subtitle": subtitle, "summary": summary, "tone": if tone.is_empty() { "整体气质仍可继续精修" } else { tone.as_str() }, "playerGoal": intent .core_conflicts .first() .cloned() .unwrap_or_else(|| "先站稳开局,再判断下一步".to_string()), "worldHook": intent.world_hook, "playerPremise": intent.player_premise, "coreConflicts": intent.core_conflicts, "playableNpcs": playable_npcs, "storyNpcs": [], "landmarks": landmarks, "factions": [], "threads": [], "chapters": [], "sceneChapters": [], }) } fn build_draft_title_from_intent(intent: &CreatorIntentRecord) -> String { let world_hook = clamp_text(intent.world_hook.as_str(), 24); if !world_hook.is_empty() { return world_hook; } let raw_setting = clamp_text(intent.raw_setting_text.as_str(), 24); if !raw_setting.is_empty() { return raw_setting; } "未命名草稿".to_string() } fn build_draft_summary_from_intent(intent: &CreatorIntentRecord) -> String { let display = build_creator_intent_display_text(intent); if !display.is_empty() { return clamp_text(display.replace('\n', " · ").as_str(), 180); } let raw_setting = clamp_text(intent.raw_setting_text.as_str(), 180); if !raw_setting.is_empty() { return raw_setting; } "还在收集你的世界锚点。".to_string() } fn build_creator_intent_display_text(intent: &CreatorIntentRecord) -> String { let mut lines = Vec::new(); if !intent.world_hook.trim().is_empty() { lines.push(format!("世界一句话:{}", intent.world_hook)); } if !intent.player_premise.trim().is_empty() { lines.push(format!("玩家身份:{}", intent.player_premise)); } if !intent.opening_situation.trim().is_empty() { lines.push(format!("开局处境:{}", intent.opening_situation)); } if !intent.core_conflicts.is_empty() { lines.push(format!("核心冲突:{}", intent.core_conflicts.join("、"))); } let theme_text = dedupe_string_list( intent .theme_keywords .iter() .cloned() .chain(intent.tone_directives.iter().cloned()) .collect::>(), 8, ) .join("、"); if !theme_text.is_empty() { lines.push(format!("主题气质:{}", theme_text)); } if !intent.iconic_elements.is_empty() { lines.push(format!("标志性要素:{}", intent.iconic_elements.join("、"))); } lines.join("\n") } fn detect_user_input_signal(chat_history: &[JsonValue]) -> PromptUserInputSignal { let latest_user_text = latest_user_text(chat_history); if latest_user_text.is_empty() { return PromptUserInputSignal::Sparse; } if contains_any( &latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻", "修正"], ) { return PromptUserInputSignal::Correction; } if contains_any( &latest_user_text, &["你帮我想", "你来定", "你决定", "你补完"], ) { return PromptUserInputSignal::Delegate; } let segments = split_sentences(&latest_user_text); if latest_user_text.chars().count() <= 10 || segments.len() <= 1 { return PromptUserInputSignal::Sparse; } if segments.len() >= 3 || latest_user_text.chars().count() >= 60 { return PromptUserInputSignal::Rich; } PromptUserInputSignal::Normal } fn detect_drift_risk( chat_history: &[JsonValue], anchor_content: &EightAnchorContent, progress_percent: u32, ) -> PromptDriftRisk { let latest_user_text = latest_user_text(chat_history); let recent_user_messages = chat_history .iter() .filter_map(|entry| { (entry.get("role").and_then(JsonValue::as_str) == Some("user")).then(|| { entry .get("content") .and_then(JsonValue::as_str) .unwrap_or("") .trim() .to_string() }) }) .filter(|value| !value.is_empty()) .rev() .take(3) .collect::>(); let correction_count = recent_user_messages .iter() .filter(|entry| { contains_any( entry, &["不是", "改成", "改为", "换成", "推翻", "重来", "修正"], ) }) .count(); if correction_count >= 2 || (progress_percent >= 65 && contains_any( &latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻"], )) { return PromptDriftRisk::High; } let filled_count = [ anchor_content.world_promise.is_some(), anchor_content.player_fantasy.is_some(), anchor_content .theme_boundary .as_ref() .map(|value| { !value.tone_keywords.is_empty() || !value.aesthetic_directives.is_empty() || !value.forbidden_directives.is_empty() }) .unwrap_or(false), anchor_content.player_entry_point.is_some(), anchor_content.core_conflict.is_some(), !anchor_content.key_relationships.is_empty(), anchor_content .hidden_lines .as_ref() .map(|value| { !value.hidden_truths.is_empty() || !value.misdirection_hints.is_empty() || !value.reveal_pacing.trim().is_empty() }) .unwrap_or(false), anchor_content .iconic_elements .as_ref() .map(|value| { !value.iconic_motifs.is_empty() || !value.institutions_or_artifacts.is_empty() || !value.hard_rules.is_empty() }) .unwrap_or(false), ] .iter() .filter(|value| **value) .count(); if filled_count >= 3 && latest_user_text.chars().count() >= 40 { PromptDriftRisk::Medium } else { PromptDriftRisk::Low } } fn pick_conversation_mode( current_turn: u32, progress_percent: u32, user_input_signal: PromptUserInputSignal, drift_risk: PromptDriftRisk, quick_fill_requested: bool, ) -> PromptConversationMode { if quick_fill_requested { return PromptConversationMode::ForceComplete; } if matches!(user_input_signal, PromptUserInputSignal::Correction) || matches!(drift_risk, PromptDriftRisk::High) { return PromptConversationMode::RepairDirection; } if progress_percent >= 85 || current_turn >= 15 { return PromptConversationMode::Closing; } if current_turn > 10 || progress_percent >= 65 { return PromptConversationMode::Compress; } if current_turn <= 10 && progress_percent < 65 { return PromptConversationMode::Expand; } PromptConversationMode::Bootstrap } fn summarize_dynamic_state( user_input_signal: PromptUserInputSignal, drift_risk: PromptDriftRisk, conversation_mode: PromptConversationMode, ) -> String { format!( "输入信号={},漂移风险={},本轮模式={}。正式生成时按这组状态执行。", user_input_signal.as_str(), drift_risk.as_str(), conversation_mode.as_str() ) } fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String { format!( "上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}", dynamic_state.user_input_signal.as_str(), dynamic_state.drift_risk.as_str(), dynamic_state.conversation_mode.as_str(), dynamic_state.judgement_summary ) } fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String { format!( "当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}", serde_json::to_string_pretty(anchor_content) .unwrap_or_else(|_| empty_agent_anchor_content_json()) ) } fn render_chat_history_context(chat_history: &[JsonValue]) -> String { format!( "以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}", serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string()) ) } 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 extract_reply_text_from_partial_json(text: &str) -> Option { let key_index = text.find("\"replyText\"")?; let colon_index = text[key_index..].find(':')? + key_index; let mut cursor = colon_index + 1; while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() { cursor += 1; } if text.as_bytes().get(cursor).copied() != Some(b'"') { return None; } cursor += 1; let mut decoded = String::new(); let remainder = text.get(cursor..)?; let mut characters = remainder.chars().peekable(); while let Some(current) = characters.next() { if current == '"' { return Some(decoded); } if current == '\\' { let escaped = characters.next()?; match escaped { '"' => decoded.push('"'), '\\' => decoded.push('\\'), '/' => decoded.push('/'), 'b' => decoded.push('\u{0008}'), 'f' => decoded.push('\u{000C}'), 'n' => decoded.push('\n'), 'r' => decoded.push('\r'), 't' => decoded.push('\t'), 'u' => { let mut hex = String::new(); for _ in 0..4 { hex.push(characters.next()?); } if let Ok(code) = u16::from_str_radix(hex.as_str(), 16) && let Some(character) = char::from_u32(code as u32) { decoded.push(character); } } other => decoded.push(other), } continue; } decoded.push(current); } Some(decoded) } fn parse_user_input_signal(value: Option<&JsonValue>) -> Option { match value.and_then(JsonValue::as_str)? { "rich" => Some(PromptUserInputSignal::Rich), "normal" => Some(PromptUserInputSignal::Normal), "sparse" => Some(PromptUserInputSignal::Sparse), "correction" => Some(PromptUserInputSignal::Correction), "delegate" => Some(PromptUserInputSignal::Delegate), _ => None, } } fn parse_drift_risk(value: Option<&JsonValue>) -> Option { match value.and_then(JsonValue::as_str)? { "low" => Some(PromptDriftRisk::Low), "medium" => Some(PromptDriftRisk::Medium), "high" => Some(PromptDriftRisk::High), _ => None, } } fn parse_conversation_mode(value: Option<&JsonValue>) -> Option { match value.and_then(JsonValue::as_str)? { "bootstrap" => Some(PromptConversationMode::Bootstrap), "expand" => Some(PromptConversationMode::Expand), "compress" => Some(PromptConversationMode::Compress), "repair_direction" => Some(PromptConversationMode::RepairDirection), "force_complete" => Some(PromptConversationMode::ForceComplete), "closing" => Some(PromptConversationMode::Closing), _ => None, } } fn mode_rules(mode: PromptConversationMode) -> &'static str { match mode { PromptConversationMode::Bootstrap => { r#"当前模式:bootstrap 目标: 1. 先把世界的基本方向抓住 2. 不要一次塞太多新设定 3. 回复要降低用户开口压力 本轮行为要求: 1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索 2. 如果用户信息很少,不要强行把整套结构一次补满 3. replyText 要像共创搭档,而不是像审问 4. 默认只推进一个最关键的问题方向 5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步 6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题 7. 不要把问题问得像表单采集,不要一口气追问多个维度 用户体验要求: 1. 让用户觉得“现在很容易继续往下说” 2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉 3. replyText 最好短、稳、可接话 4. 如果用户信息很少,也不要显得冷淡或机械"# } PromptConversationMode::Expand => { r#"当前模式:expand 目标: 1. 在保持现有方向的前提下,把设定结构逐步补全 2. 尽量让一轮输入覆盖多个关键维度 本轮行为要求: 1. 继续保留上一版里仍成立的设定 2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段 3. replyText 要明确体现“你已经理解了哪些内容” 4. 不要突然大幅改写已经成形的世界 5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步 6. 可以适度替用户整理,但不要把回复写成总结报告 7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感 用户体验要求: 1. 让用户感到“我刚说的内容都被接住了” 2. 回复里可以带一点顺势整理感,但不要太像会议纪要 3. 不要无视用户刚提供的高价值细节 4. 不要让用户觉得系统在自顾自重写世界"# } PromptConversationMode::Compress => { r#"当前模式:compress 目标: 1. 开始收束当前设定 2. 减少无效发散 3. 让 progress 更接近可进入下一阶段 本轮行为要求: 1. 新的设定结构优先保留稳定内容,不要无端重写 2. 对用户本轮输入做高密度吸收 3. replyText 要更聚焦,不要绕圈 4. 默认只推进当前最影响 completion 的一步 5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支 6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist 7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问 用户体验要求: 1. 让用户感觉世界正在变得更稳,而不是越来越散 2. 让推进感更明确,但不要显得催促 3. 回复语气应更笃定一些,减少反复横跳 4. 不要把用户刚补进来的细节又冲淡掉"# } PromptConversationMode::RepairDirection => { r#"当前模式:repair_direction 目标: 1. 处理用户对既有设定的修正 2. 避免世界方向飘散或自相矛盾 本轮行为要求: 1. 如果用户明确改口,新的设定结构必须体现修正后的方向 2. 对已经不再成立的旧设定,不要机械保留 3. progressPercent 可以停滞,也可以小幅回落,但不能为负 4. replyText 要承认用户的修正,并顺着修正后的方向继续聊 5. 先处理“改掉什么”,再决定“往哪里继续推” 6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向 7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境 用户体验要求: 1. 让用户感到“我刚刚的纠偏真的生效了” 2. 不要和用户辩论旧方案为什么也行 3. 不要表现出对修正的不情愿 4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"# } PromptConversationMode::ForceComplete => { r#"当前模式:force_complete 目标: 1. 基于当前方向直接补齐剩余设定 2. 生成一版尽量完整、可进入下一阶段的设定结构 3. 结束当前收集阶段 本轮行为要求: 1. 尽量保留已经形成的世界方向 2. 对明显缺失的关键维度进行合理补全 3. 不要继续拉长聊天,不要再追问用户 4. progressPercent 直接输出为 100 5. replyText 要自然引导用户点击“生成游戏设定草稿” 6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突 7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经 8. replyText 更像阶段完成提示,不再像继续采集信息的对话 用户体验要求: 1. 让用户感到“系统已经帮我把能补的补好了” 2. 不要在这一步突然冒出很多陌生设定把用户吓出戏 3. 回复要有完成感,但不要太官话 4. 清楚告诉用户下一步可以做什么"# } PromptConversationMode::Closing => { r#"当前模式:closing 目标: 1. 尽量形成一版可用的设定底子 2. 不再继续发散新世界观 本轮行为要求: 1. 优先收束,而不是扩写 2. 不要大改已经成形的核心设定 3. progressPercent 接近完成时,replyText 要更像确认与推进 4. 如果用户没有大改方向,尽量让下一版内容更稳定 5. 可以轻微补足缺口,但不要再大开新支线 6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感 7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题 用户体验要求: 1. 让用户感觉作品已经快成了,而不是还在无穷试探 2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探 3. 保持留白感,不要把所有东西都一次说死 4. 让用户自然过渡到下一阶段,而不是突然被切断对话"# } } } fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str { match signal { PromptUserInputSignal::Rich => { r#"本轮用户输入信息密度高。 请尽量从这一轮里提取多个锚点,不要只更新单一方向。 如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"# } PromptUserInputSignal::Normal => { r#"本轮用户输入为正常补充。 请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"# } PromptUserInputSignal::Sparse => { r#"本轮用户输入较少或较虚。 请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。 replyText 要让用户容易继续往下说。"# } PromptUserInputSignal::Correction => { r#"本轮用户在修正或推翻旧设定。 请优先吸收修正,不要机械复读旧版本。 新的完整设定结构必须以修正后的方向为准。"# } PromptUserInputSignal::Delegate => { r#"本轮用户把部分决定权交给你。 你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。 新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"# } } } fn latest_user_text(chat_history: &[JsonValue]) -> String { chat_history .iter() .rev() .find(|entry| entry.get("role").and_then(JsonValue::as_str) == Some("user")) .and_then(|entry| entry.get("content").and_then(JsonValue::as_str)) .map(str::trim) .unwrap_or_default() .to_string() } fn split_sentences(text: &str) -> Vec { text.split(|character| matches!(character, '。' | '!' | '?' | ';' | '\n')) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect() } fn compact_lines(items: I) -> String where I: IntoIterator>, S: AsRef, { items .into_iter() .filter_map(|item| item.map(|value| value.as_ref().trim().to_string())) .filter(|value| !value.is_empty()) .collect::>() .join(";") } fn split_relationship_pair(value: &str) -> (String, String) { let replaced = value.replace(['、', '/', '/', '&', '|'], " "); let segments = replaced .split(['与', '和', ' ']) .map(str::trim) .filter(|value| !value.is_empty()) .collect::>(); let meaningful = segments .iter() .copied() .filter(|item| !matches!(*item, "玩家" | "主角" | "我")) .collect::>(); ( meaningful.first().copied().unwrap_or_default().to_string(), if segments.len() >= 2 { segments.join(" / ") } else { value.trim().to_string() }, ) } fn dedupe_string_list(values: Vec, max_count: usize) -> Vec { let mut seen = std::collections::BTreeSet::new(); let mut result = Vec::new(); for value in values { let normalized = value.trim().to_string(); if normalized.is_empty() || !seen.insert(normalized.clone()) { continue; } result.push(normalized); if result.len() >= max_count { break; } } result } fn contains_any(text: &str, patterns: &[&str]) -> bool { patterns.iter().any(|pattern| text.contains(pattern)) } fn clamp_text(value: &str, max_length: usize) -> String { let normalized = value.split_whitespace().collect::>().join(" "); if normalized.is_empty() { return String::new(); } if normalized.chars().count() <= max_length { return normalized; } normalized .chars() .take(max_length.saturating_sub(1)) .collect::() + "…" } fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 { let Some(number) = value.and_then(JsonValue::as_f64) else { return 0; }; number.round().clamp(0.0, 100.0) as u32 } fn to_text(value: Option<&JsonValue>) -> Option { value .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn serialize_json(value: &JsonValue, fallback: &str) -> String { serde_json::to_string(value).unwrap_or_else(|_| fallback.to_string()) } impl PromptUserInputSignal { fn as_str(self) -> &'static str { match self { Self::Rich => "rich", Self::Normal => "normal", Self::Sparse => "sparse", Self::Correction => "correction", Self::Delegate => "delegate", } } } impl PromptDriftRisk { fn as_str(self) -> &'static str { match self { Self::Low => "low", Self::Medium => "medium", Self::High => "high", } } } impl PromptConversationMode { fn as_str(self) -> &'static str { match self { Self::Bootstrap => "bootstrap", Self::Expand => "expand", Self::Compress => "compress", Self::RepairDirection => "repair_direction", Self::ForceComplete => "force_complete", Self::Closing => "closing", } } } #[cfg(test)] mod tests { use super::extract_reply_text_from_partial_json; #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#; let extracted = extract_reply_text_from_partial_json(partial_json); assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛")); } }