Files
Genarrative/server-rs/crates/api-server/src/custom_world_agent_turn.rs
2026-04-23 23:38:00 +08:00

2125 lines
71 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use 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<String>,
}
#[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<String>,
pub anchor_content_json: String,
pub creator_intent_json: Option<String>,
pub creator_intent_readiness_json: String,
pub anchor_pack_json: Option<String>,
pub draft_profile_json: Option<String>,
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<String>,
}
#[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<PromptUserInputSignal>,
drift_risk: Option<PromptDriftRisk>,
conversation_mode: Option<PromptConversationMode>,
judgement_summary: Option<String>,
}
#[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<String>,
#[serde(default)]
aesthetic_directives: Vec<String>,
#[serde(default)]
forbidden_directives: Vec<String>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
misdirection_hints: Vec<String>,
#[serde(default)]
reveal_pacing: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IconicElementValue {
#[serde(default)]
iconic_motifs: Vec<String>,
#[serde(default)]
institutions_or_artifacts: Vec<String>,
#[serde(default)]
hard_rules: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EightAnchorContent {
#[serde(default)]
world_promise: Option<WorldPromiseValue>,
#[serde(default)]
player_fantasy: Option<PlayerFantasyValue>,
#[serde(default)]
theme_boundary: Option<ThemeBoundaryValue>,
#[serde(default)]
player_entry_point: Option<PlayerEntryPointValue>,
#[serde(default)]
core_conflict: Option<CoreConflictValue>,
#[serde(default)]
key_relationships: Vec<KeyRelationshipValue>,
#[serde(default)]
hidden_lines: Option<HiddenLineValue>,
#[serde(default)]
iconic_elements: Option<IconicElementValue>,
}
#[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<String>,
tone_directives: Vec<String>,
player_premise: String,
opening_situation: String,
core_conflicts: Vec<String>,
key_characters: Vec<CreatorCharacterSeedRecord>,
key_landmarks: Vec<CreatorLandmarkSeedRecord>,
iconic_elements: Vec<String>,
forbidden_directives: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreatorIntentReadiness {
is_ready: bool,
completed_keys: Vec<String>,
missing_keys: Vec<String>,
}
#[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<String>) -> 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. judgementSummary1 到 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 为 trueconversationMode 必须优先判为 force_complete
2. 如果用户核心意图是修正旧方向userInputSignal 优先判为 correctionconversationMode 通常优先考虑 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<F>(
request: CustomWorldAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<CustomWorldAgentTurnResult, CustomWorldTurnError>
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,
&current_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::<Vec<_>>(),
),
&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::<Vec<_>>(),
),
&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<String> {
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::<Vec<_>>(),
),
&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<F>(
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<SingleTurnModelOutput, CustomWorldTurnError>
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<PromptDynamicStateInference>,
) -> 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<JsonValue> {
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::<EightAnchorContent>(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::<Vec<_>>();
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::<Vec<_>>()
})
.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::<Vec<_>>()
})
.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::<Vec<_>>(),
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<JsonValue> {
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::<Vec<_>>(),
"motifDirectives": dedupe_string_list(
intent
.theme_keywords
.iter()
.cloned()
.chain(intent.tone_directives.iter().cloned())
.chain(intent.iconic_elements.iter().cloned())
.collect::<Vec<_>>(),
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::<Vec<_>>(),
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(),
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::<Vec<_>>();
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<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
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<PromptUserInputSignal> {
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<PromptDriftRisk> {
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<PromptConversationMode> {
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<String> {
text.split(|character| matches!(character, '。' | '' | '' | '' | '\n'))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn compact_lines<I, S>(items: I) -> String
where
I: IntoIterator<Item = Option<S>>,
S: AsRef<str>,
{
items
.into_iter()
.filter_map(|item| item.map(|value| value.as_ref().trim().to_string()))
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.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::<Vec<_>>();
let meaningful = segments
.iter()
.copied()
.filter(|item| !matches!(*item, "玩家" | "主角" | ""))
.collect::<Vec<_>>();
(
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<String>, max_count: usize) -> Vec<String> {
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::<Vec<_>>().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::<String>()
+ ""
}
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<String> {
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("你好,潮雾列岛"));
}
}