Prune obsolete docs and update navigation
This commit is contained in:
@@ -726,7 +726,9 @@ fn build_foundation_draft_profile_from_framework(
|
||||
)])
|
||||
}),
|
||||
);
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }));
|
||||
let camp = framework.get("camp").cloned().unwrap_or_else(
|
||||
|| json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
|
||||
);
|
||||
object.insert("camp".to_string(), camp.clone());
|
||||
object.insert(
|
||||
"playableNpcs".to_string(),
|
||||
@@ -927,7 +929,10 @@ fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
|
||||
object.insert("coreConflicts".to_string(), JsonValue::Array(Vec::new()));
|
||||
}
|
||||
if !object.get("camp").is_some_and(JsonValue::is_object) {
|
||||
object.insert("camp".to_string(), json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }));
|
||||
object.insert(
|
||||
"camp".to_string(),
|
||||
json!({ "name": "开局归处", "description": "玩家进入世界后的第一处落脚点。" }),
|
||||
);
|
||||
}
|
||||
if let Some(camp) = object.get_mut("camp").and_then(JsonValue::as_object_mut) {
|
||||
let camp_name = camp
|
||||
|
||||
@@ -44,6 +44,7 @@ mod request_context;
|
||||
mod response_headers;
|
||||
mod runtime_browse_history;
|
||||
mod runtime_chat;
|
||||
mod runtime_chat_prompt;
|
||||
mod runtime_inventory;
|
||||
mod runtime_profile;
|
||||
mod runtime_save;
|
||||
|
||||
@@ -164,4 +164,3 @@ fn conditional_prompt_line(prefix: &str, value: &str) -> String {
|
||||
format!("{prefix}:{value}。")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Extension,
|
||||
extract::{Extension, State},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{http_error::AppError, request_context::RequestContext};
|
||||
use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
runtime_chat_prompt::{
|
||||
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
|
||||
NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt,
|
||||
build_npc_chat_turn_suggestion_prompt,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NpcChatTurnRequest {
|
||||
#[serde(default)]
|
||||
world_type: String,
|
||||
#[serde(default)]
|
||||
character: Option<Value>,
|
||||
#[serde(default)]
|
||||
player: Option<Value>,
|
||||
encounter: Value,
|
||||
#[serde(default)]
|
||||
monsters: Vec<Value>,
|
||||
#[serde(default)]
|
||||
history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
context: Value,
|
||||
#[serde(default)]
|
||||
conversation_history: Vec<Value>,
|
||||
#[serde(default)]
|
||||
dialogue: Vec<Value>,
|
||||
#[serde(default)]
|
||||
combat_context: Option<Value>,
|
||||
player_message: String,
|
||||
#[serde(default)]
|
||||
npc_state: Value,
|
||||
#[serde(default)]
|
||||
npc_initiates_conversation: bool,
|
||||
#[serde(default)]
|
||||
chat_directive: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_chat_turn(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Json(payload): Json<NpcChatTurnRequest>,
|
||||
) -> Result<Response, Response> {
|
||||
@@ -38,27 +69,128 @@ pub async fn stream_runtime_npc_chat_turn(
|
||||
));
|
||||
}
|
||||
|
||||
let npc_reply = build_deterministic_npc_reply(
|
||||
npc_name.as_str(),
|
||||
player_message,
|
||||
payload.npc_initiates_conversation,
|
||||
);
|
||||
let suggestions = build_deterministic_chat_suggestions(npc_name.as_str(), player_message);
|
||||
let llm_result =
|
||||
generate_llm_npc_chat_turn(&state, &request_context, &payload, &npc_name).await;
|
||||
let (mut body, npc_reply, suggestions) = match llm_result {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
let npc_reply = build_deterministic_npc_reply(
|
||||
npc_name.as_str(),
|
||||
player_message,
|
||||
payload.npc_initiates_conversation,
|
||||
);
|
||||
let suggestions = if should_force_chat_exit(payload.chat_directive.as_ref()) {
|
||||
Vec::new()
|
||||
} else {
|
||||
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
|
||||
};
|
||||
let mut body = String::new();
|
||||
append_sse_event(
|
||||
&request_context,
|
||||
&mut body,
|
||||
"reply_delta",
|
||||
&json!({ "text": npc_reply }),
|
||||
)?;
|
||||
(body, npc_reply, suggestions)
|
||||
}
|
||||
};
|
||||
|
||||
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
|
||||
let affinity_delta =
|
||||
compute_npc_chat_affinity_delta(player_message, npc_reply.as_str(), chatted_count);
|
||||
let complete_payload = json!({
|
||||
"npcReply": npc_reply,
|
||||
"affinityDelta": 0,
|
||||
"affinityText": "关系暂未变化",
|
||||
"affinityDelta": affinity_delta,
|
||||
"affinityText": describe_affinity_shift(affinity_delta),
|
||||
"suggestions": suggestions,
|
||||
"pendingQuestOffer": null,
|
||||
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
|
||||
});
|
||||
|
||||
let mut body = String::new();
|
||||
append_sse_event(&request_context, &mut body, "reply_delta", &json!({ "text": npc_reply }))?;
|
||||
append_sse_event(&request_context, &mut body, "complete", &complete_payload)?;
|
||||
body.push_str("data: [DONE]\n\n");
|
||||
Ok(build_event_stream_response(body))
|
||||
}
|
||||
|
||||
async fn generate_llm_npc_chat_turn(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
payload: &NpcChatTurnRequest,
|
||||
npc_name: &str,
|
||||
) -> Option<(String, String, Vec<String>)> {
|
||||
let llm_client = state.llm_client()?;
|
||||
let character = payload
|
||||
.character
|
||||
.as_ref()
|
||||
.or(payload.player.as_ref())
|
||||
.unwrap_or(&Value::Null);
|
||||
let prompt_input = NpcChatTurnPromptInput {
|
||||
world_type: payload.world_type.as_str(),
|
||||
character,
|
||||
encounter: &payload.encounter,
|
||||
monsters: &payload.monsters,
|
||||
history: &payload.history,
|
||||
context: &payload.context,
|
||||
conversation_history: &payload.conversation_history,
|
||||
dialogue: &payload.dialogue,
|
||||
combat_context: payload.combat_context.as_ref(),
|
||||
player_message: payload.player_message.as_str(),
|
||||
npc_state: &payload.npc_state,
|
||||
npc_initiates_conversation: payload.npc_initiates_conversation,
|
||||
chat_directive: payload.chat_directive.as_ref(),
|
||||
};
|
||||
|
||||
let mut body = String::new();
|
||||
let reply_prompt = build_npc_chat_turn_reply_prompt(&prompt_input);
|
||||
let mut reply_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT),
|
||||
LlmMessage::user(reply_prompt),
|
||||
]);
|
||||
reply_request.max_tokens = Some(700);
|
||||
reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
|
||||
|
||||
let reply_response = llm_client
|
||||
.stream_text(reply_request, |delta| {
|
||||
let _ = append_sse_event(
|
||||
request_context,
|
||||
&mut body,
|
||||
"reply_delta",
|
||||
&json!({ "text": delta.accumulated_text }),
|
||||
);
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
let npc_reply = normalize_required_text(reply_response.content.as_str()).unwrap_or_else(|| {
|
||||
build_deterministic_npc_reply(
|
||||
npc_name,
|
||||
payload.player_message.as_str(),
|
||||
payload.npc_initiates_conversation,
|
||||
)
|
||||
});
|
||||
|
||||
if should_force_chat_exit(payload.chat_directive.as_ref()) {
|
||||
return Some((body, npc_reply, Vec::new()));
|
||||
}
|
||||
|
||||
let suggestion_prompt =
|
||||
build_npc_chat_turn_suggestion_prompt(&prompt_input, npc_reply.as_str());
|
||||
let mut suggestion_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT),
|
||||
LlmMessage::user(suggestion_prompt),
|
||||
]);
|
||||
suggestion_request.max_tokens = Some(200);
|
||||
suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled;
|
||||
let suggestions = llm_client
|
||||
.request_text(suggestion_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| parse_line_list_content(response.content.as_str(), 3))
|
||||
.filter(|items| items.len() == 3)
|
||||
.unwrap_or_else(|| build_fallback_npc_chat_suggestions(payload.player_message.as_str()));
|
||||
|
||||
Some((body, npc_reply, suggestions))
|
||||
}
|
||||
|
||||
fn build_deterministic_npc_reply(
|
||||
npc_name: &str,
|
||||
player_message: &str,
|
||||
@@ -84,15 +216,39 @@ fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) ->
|
||||
]
|
||||
}
|
||||
|
||||
fn build_fallback_npc_chat_suggestions(player_message: &str) -> Vec<String> {
|
||||
let topic = player_message.trim().chars().take(8).collect::<String>();
|
||||
let topic = if topic.is_empty() {
|
||||
"刚才那句".to_string()
|
||||
} else {
|
||||
topic
|
||||
};
|
||||
|
||||
vec![
|
||||
"你刚才那句是什么意思".to_string(),
|
||||
format!("这事和{topic}有关吗"),
|
||||
"你愿意再说清楚点吗".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_completion_directive(chat_directive: Option<&Value>) -> Value {
|
||||
let Some(directive) = chat_directive else {
|
||||
return Value::Null;
|
||||
};
|
||||
let closing_mode = read_string_field(directive, "closingMode")
|
||||
.filter(|value| value == "foreshadow_close")
|
||||
.unwrap_or_else(|| "free".to_string());
|
||||
let force_exit = closing_mode == "foreshadow_close"
|
||||
|| directive
|
||||
.get("forceExitAfterTurn")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
json!({
|
||||
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
|
||||
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
|
||||
"forceExit": directive.get("forceExitAfterTurn").and_then(Value::as_bool).unwrap_or(false),
|
||||
"closingMode": directive.get("closingMode").cloned().unwrap_or(Value::Null),
|
||||
"forceExit": force_exit,
|
||||
"closingMode": closing_mode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +261,124 @@ fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_number_field(value: &Value, field: &str) -> Option<f64> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|number| number.is_finite())
|
||||
}
|
||||
|
||||
fn should_force_chat_exit(chat_directive: Option<&Value>) -> bool {
|
||||
let Some(directive) = chat_directive else {
|
||||
return false;
|
||||
};
|
||||
|
||||
read_string_field(directive, "closingMode").as_deref() == Some("foreshadow_close")
|
||||
|| directive
|
||||
.get("forceExitAfterTurn")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str) -> Option<String> {
|
||||
let normalized = value.trim();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
|
||||
fn parse_line_list_content(text: &str, max_items: usize) -> Vec<String> {
|
||||
text.replace('\r', "")
|
||||
.lines()
|
||||
.map(|line| trim_line_list_marker(line.trim()).trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.take(max_items)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn trim_line_list_marker(line: &str) -> &str {
|
||||
line.trim_start_matches(|character: char| {
|
||||
character == '-'
|
||||
|| character == '*'
|
||||
|| character.is_ascii_digit()
|
||||
|| character == '.'
|
||||
|| character == ')'
|
||||
|| character.is_whitespace()
|
||||
})
|
||||
}
|
||||
|
||||
fn count_keyword_matches(text: &str, keywords: &[&str]) -> i32 {
|
||||
keywords
|
||||
.iter()
|
||||
.filter(|keyword| text.contains(**keyword))
|
||||
.count() as i32
|
||||
}
|
||||
|
||||
fn clamp_affinity_delta(value: i32) -> i32 {
|
||||
value.clamp(-3, 3)
|
||||
}
|
||||
|
||||
fn compute_npc_chat_affinity_delta(
|
||||
player_message: &str,
|
||||
npc_reply: &str,
|
||||
chatted_count: f64,
|
||||
) -> i32 {
|
||||
let positive_keywords = [
|
||||
"谢谢", "辛苦", "抱歉", "理解", "相信", "放心", "一起", "帮你", "在意", "关心",
|
||||
];
|
||||
let negative_keywords = [
|
||||
"闭嘴",
|
||||
"滚",
|
||||
"少废话",
|
||||
"威胁",
|
||||
"骗",
|
||||
"不信",
|
||||
"别装",
|
||||
"快说",
|
||||
"审问",
|
||||
"怀疑",
|
||||
];
|
||||
let warm_reply_keywords = ["可以", "愿意", "放心", "谢谢", "明白", "好"];
|
||||
let cold_reply_keywords = ["没必要", "不想", "别问", "与你无关", "算了", "住口"];
|
||||
|
||||
let positive_score = count_keyword_matches(player_message.trim(), &positive_keywords)
|
||||
+ count_keyword_matches(npc_reply.trim(), &warm_reply_keywords);
|
||||
let negative_score = count_keyword_matches(player_message.trim(), &negative_keywords)
|
||||
+ count_keyword_matches(npc_reply.trim(), &cold_reply_keywords);
|
||||
|
||||
if positive_score == 0 && negative_score == 0 {
|
||||
return if chatted_count == 0.0 { 1 } else { 0 };
|
||||
}
|
||||
|
||||
if positive_score > negative_score {
|
||||
let base_delta = positive_score - negative_score + if chatted_count <= 1.0 { 1 } else { 0 };
|
||||
return clamp_affinity_delta(base_delta);
|
||||
}
|
||||
|
||||
if negative_score > positive_score {
|
||||
return clamp_affinity_delta(positive_score - negative_score);
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
fn describe_affinity_shift(affinity_delta: i32) -> &'static str {
|
||||
if affinity_delta >= 8 {
|
||||
return "态度明显软化了下来。";
|
||||
}
|
||||
if affinity_delta >= 5 {
|
||||
return "态度比刚才亲近了一些。";
|
||||
}
|
||||
if affinity_delta > 0 {
|
||||
return "对话气氛稍微松动了一点。";
|
||||
}
|
||||
if affinity_delta < 0 {
|
||||
return "这轮对话让气氛变得更紧了一些。";
|
||||
}
|
||||
"这轮对话暂时没有带来明显关系变化。"
|
||||
}
|
||||
|
||||
fn append_sse_event(
|
||||
request_context: &RequestContext,
|
||||
body: &mut String,
|
||||
|
||||
549
server-rs/crates/api-server/src/runtime_chat_prompt.rs
Normal file
549
server-rs/crates/api-server/src/runtime_chat_prompt.rs
Normal file
@@ -0,0 +1,549 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
|
||||
你只输出这名 NPC 此刻会对玩家说的一轮回复。
|
||||
只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
|
||||
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
|
||||
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
|
||||
|
||||
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
|
||||
只输出纯文本,共 3 行,每行 1 条。
|
||||
不要加编号、项目符号、Markdown、JSON 或额外说明。
|
||||
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。"#;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct NpcChatTurnPromptInput<'a> {
|
||||
pub world_type: &'a str,
|
||||
pub character: &'a Value,
|
||||
pub encounter: &'a Value,
|
||||
pub monsters: &'a [Value],
|
||||
pub history: &'a [Value],
|
||||
pub context: &'a Value,
|
||||
pub conversation_history: &'a [Value],
|
||||
pub dialogue: &'a [Value],
|
||||
pub combat_context: Option<&'a Value>,
|
||||
pub player_message: &'a str,
|
||||
pub npc_state: &'a Value,
|
||||
pub npc_initiates_conversation: bool,
|
||||
pub chat_directive: Option<&'a Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
|
||||
let encounter = describe_encounter(payload.encounter);
|
||||
let context = as_record(payload.context);
|
||||
let npc_state = as_record(payload.npc_state);
|
||||
let chat_directive = payload.chat_directive.and_then(as_record);
|
||||
let conversation_history = if !payload.conversation_history.is_empty() {
|
||||
payload.conversation_history
|
||||
} else {
|
||||
payload.dialogue
|
||||
};
|
||||
let opening_camp_background =
|
||||
context.and_then(|record| read_string(record.get("openingCampBackground")));
|
||||
let opening_camp_dialogue =
|
||||
context.and_then(|record| read_string(record.get("openingCampDialogue")));
|
||||
let allowed_topics = context
|
||||
.and_then(|record| record.get("encounterAllowedTopics"))
|
||||
.map(read_string_array)
|
||||
.unwrap_or_default();
|
||||
let blocked_topics = context
|
||||
.and_then(|record| record.get("encounterBlockedTopics"))
|
||||
.map(read_string_array)
|
||||
.unwrap_or_default();
|
||||
let is_first_meaningful_contact = context
|
||||
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
|
||||
.unwrap_or(false);
|
||||
let affinity = npc_state
|
||||
.and_then(|record| read_number(record.get("affinity")))
|
||||
.unwrap_or(0.0);
|
||||
let chatted_count = npc_state
|
||||
.and_then(|record| read_number(record.get("chattedCount")))
|
||||
.unwrap_or(0.0);
|
||||
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
|
||||
let turn_limit = chat_directive
|
||||
.and_then(|record| read_number(record.get("turnLimit")))
|
||||
.unwrap_or(0.0)
|
||||
.max(0.0);
|
||||
let remaining_turns = chat_directive
|
||||
.and_then(|record| read_number(record.get("remainingTurns")))
|
||||
.unwrap_or(0.0)
|
||||
.max(0.0);
|
||||
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
|
||||
let is_limited_negative_affinity_chat =
|
||||
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
|
||||
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|
||||
|| chat_directive
|
||||
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
|
||||
.unwrap_or(false);
|
||||
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
|
||||
as_record(item)
|
||||
.and_then(|turn| read_string(turn.get("speaker")))
|
||||
.is_some_and(|speaker| speaker == "npc")
|
||||
});
|
||||
let is_first_npc_spoken_turn =
|
||||
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
|
||||
let first_contact_relation_stance = describe_first_contact_relation_stance(
|
||||
context.and_then(|record| record.get("firstContactRelationStance")),
|
||||
);
|
||||
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
|
||||
|
||||
[
|
||||
Some(build_npc_dialogue_prompt_base(payload)),
|
||||
Some(describe_npc_conversation_history(
|
||||
conversation_history,
|
||||
encounter.npc_name.as_str(),
|
||||
)),
|
||||
combat_context_block,
|
||||
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
|
||||
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
|
||||
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
|
||||
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
|
||||
if is_first_npc_spoken_turn {
|
||||
Some(format!(
|
||||
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
|
||||
encounter.npc_name
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_first_npc_spoken_turn {
|
||||
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_first_npc_spoken_turn {
|
||||
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if payload.npc_initiates_conversation {
|
||||
Some(format!(
|
||||
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
|
||||
encounter.npc_name
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if allowed_topics.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("当前更适合先谈:{}", allowed_topics.join("、")))
|
||||
},
|
||||
if blocked_topics.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("当前避免直接说破:{}", blocked_topics.join("、")))
|
||||
},
|
||||
if is_limited_negative_affinity_chat {
|
||||
Some(format!(
|
||||
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
|
||||
format_prompt_number(turn_limit)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_limited_negative_affinity_chat {
|
||||
Some(format!(
|
||||
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
|
||||
format_prompt_number(remaining_turns)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
|
||||
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_foreshadow_close_turn {
|
||||
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_foreshadow_close_turn {
|
||||
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if is_foreshadow_close_turn {
|
||||
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
if payload.npc_initiates_conversation {
|
||||
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
|
||||
} else {
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
|
||||
},
|
||||
if payload.npc_initiates_conversation {
|
||||
Some(format!(
|
||||
"现在请只写 {} 主动开口时会说的话。",
|
||||
encounter.npc_name
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
"现在请只写 {} 这一轮会回复玩家的话。",
|
||||
encounter.npc_name
|
||||
))
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
|
||||
payload: &NpcChatTurnPromptInput<'_>,
|
||||
npc_reply: &str,
|
||||
) -> String {
|
||||
let encounter = describe_encounter(payload.encounter);
|
||||
let conversation_history = if !payload.conversation_history.is_empty() {
|
||||
payload.conversation_history
|
||||
} else {
|
||||
payload.dialogue
|
||||
};
|
||||
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
|
||||
|
||||
[
|
||||
Some(build_npc_dialogue_prompt_base(payload)),
|
||||
Some(describe_npc_conversation_history(
|
||||
conversation_history,
|
||||
encounter.npc_name.as_str(),
|
||||
)),
|
||||
combat_context_block,
|
||||
Some(format!("玩家刚刚说:{}", payload.player_message)),
|
||||
Some(format!("NPC 刚刚回复:{npc_reply}")),
|
||||
Some("请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。".to_string()),
|
||||
Some("每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。".to_string()),
|
||||
Some("每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
|
||||
let encounter = describe_encounter(payload.encounter);
|
||||
|
||||
[
|
||||
format!("世界:{}", describe_world(payload.world_type)),
|
||||
describe_scene_context(payload.context),
|
||||
describe_character("玩家 / ", payload.character),
|
||||
encounter.block,
|
||||
describe_monsters(payload.monsters),
|
||||
describe_story_history(payload.history),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
struct EncounterDescription {
|
||||
npc_name: String,
|
||||
block: String,
|
||||
}
|
||||
|
||||
fn describe_encounter(encounter: &Value) -> EncounterDescription {
|
||||
let record = as_record(encounter);
|
||||
let npc_name = record
|
||||
.and_then(|item| read_string(item.get("npcName")))
|
||||
.unwrap_or_else(|| "眼前角色".to_string());
|
||||
let context_text = record
|
||||
.and_then(|item| read_string(item.get("context")))
|
||||
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
|
||||
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
|
||||
|
||||
EncounterDescription {
|
||||
npc_name: npc_name.clone(),
|
||||
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
|
||||
match value.and_then(|item| item.as_str()).map(str::trim) {
|
||||
Some("guarded") => "戒备试探".to_string(),
|
||||
Some("neutral") => "正常交流但仍不熟".to_string(),
|
||||
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
|
||||
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
|
||||
_ => "第一次真正接触".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_world(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "边城模板".to_string(),
|
||||
"XIANXIA" => "灵潮模板".to_string(),
|
||||
"CUSTOM" => "自定义世界".to_string(),
|
||||
value if !value.trim().is_empty() => value.to_string(),
|
||||
_ => "未知世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
|
||||
let hp = record
|
||||
.and_then(|item| read_number(item.get("hp")))
|
||||
.unwrap_or(0.0);
|
||||
let max_hp = record
|
||||
.and_then(|item| read_number(item.get("maxHp")))
|
||||
.unwrap_or(hp)
|
||||
.max(1.0);
|
||||
let mana = record
|
||||
.and_then(|item| read_number(item.get("mana")))
|
||||
.unwrap_or(0.0);
|
||||
let max_mana = record
|
||||
.and_then(|item| read_number(item.get("maxMana")))
|
||||
.unwrap_or(mana)
|
||||
.max(1.0);
|
||||
|
||||
format!(
|
||||
"{label}生命 {}/{},灵力 {}/{}",
|
||||
format_prompt_number(hp),
|
||||
format_prompt_number(max_hp),
|
||||
format_prompt_number(mana),
|
||||
format_prompt_number(max_mana)
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_character(label: &str, value: &Value) -> String {
|
||||
let record = as_record(value);
|
||||
let name = record
|
||||
.and_then(|item| read_string(item.get("name")))
|
||||
.unwrap_or_else(|| "未知角色".to_string());
|
||||
let title = record
|
||||
.and_then(|item| read_string(item.get("title")))
|
||||
.unwrap_or_else(|| "未知称号".to_string());
|
||||
let description = record
|
||||
.and_then(|item| read_string(item.get("description")))
|
||||
.unwrap_or_else(|| "暂无额外描述".to_string());
|
||||
let personality = record
|
||||
.and_then(|item| read_string(item.get("personality")))
|
||||
.unwrap_or_else(|| "性格信息未显式提供".to_string());
|
||||
|
||||
[
|
||||
format!("{label}姓名:{name}"),
|
||||
format!("{label}称号:{title}"),
|
||||
format!("{label}描述:{description}"),
|
||||
format!("{label}性格:{personality}"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn describe_story_history(history: &[Value]) -> String {
|
||||
if history.is_empty() {
|
||||
return "近期剧情:暂无。".to_string();
|
||||
}
|
||||
|
||||
let lines = history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if lines.is_empty() {
|
||||
"近期剧情:暂无。".to_string()
|
||||
} else {
|
||||
let mut result = vec!["近期剧情:".to_string()];
|
||||
result.extend(lines.into_iter().map(|line| format!("- {line}")));
|
||||
result.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
|
||||
if history.is_empty() {
|
||||
return "当前聊天记录:暂无。".to_string();
|
||||
}
|
||||
|
||||
let lines = history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let speaker = read_string(record.get("speaker"));
|
||||
let speaker_name = read_string(record.get("speakerName"));
|
||||
let text = read_string(record.get("text"))?;
|
||||
|
||||
match speaker.as_deref() {
|
||||
Some("player") => Some(format!("- 玩家:{text}")),
|
||||
Some("npc") => Some(format!(
|
||||
"- {}:{text}",
|
||||
speaker_name.unwrap_or_else(|| npc_name.to_string())
|
||||
)),
|
||||
Some("system") => Some(format!("- 系统提示:{text}")),
|
||||
_ => Some(format!(
|
||||
"- {}:{text}",
|
||||
speaker_name.unwrap_or_else(|| "同伴".to_string())
|
||||
)),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if lines.is_empty() {
|
||||
"当前聊天记录:暂无。".to_string()
|
||||
} else {
|
||||
let mut result = vec!["当前聊天记录:".to_string()];
|
||||
result.extend(lines);
|
||||
result.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
|
||||
let record = as_record(combat_context)?;
|
||||
let summary = read_string(record.get("summary"));
|
||||
let battle_outcome = read_string(record.get("battleOutcome"));
|
||||
let log_lines = record
|
||||
.get("logLines")
|
||||
.map(read_string_array)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.take(6)
|
||||
.collect::<Vec<_>>();
|
||||
if summary.is_none() && log_lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let outcome_text = match battle_outcome.as_deref() {
|
||||
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
|
||||
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let mut lines = vec!["刚刚结束的交锋:".to_string()];
|
||||
if let Some(text) = outcome_text {
|
||||
lines.push(text);
|
||||
}
|
||||
if let Some(text) = summary {
|
||||
lines.push(format!("- 结果摘要:{text}"));
|
||||
}
|
||||
if !log_lines.is_empty() {
|
||||
lines.push("- 战斗日志:".to_string());
|
||||
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn describe_scene_context(context: &Value) -> String {
|
||||
let record = as_record(context);
|
||||
let scene_name = record
|
||||
.and_then(|item| read_string(item.get("sceneName")))
|
||||
.unwrap_or_else(|| "当前区域".to_string());
|
||||
let scene_description = record
|
||||
.and_then(|item| read_string(item.get("sceneDescription")))
|
||||
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
|
||||
let in_battle = if record
|
||||
.and_then(|item| read_bool(item.get("inBattle")))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"战斗中"
|
||||
} else {
|
||||
"非战斗"
|
||||
};
|
||||
let custom_world_profile = record
|
||||
.and_then(|item| item.get("customWorldProfile"))
|
||||
.and_then(as_record);
|
||||
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
|
||||
let custom_world_summary =
|
||||
custom_world_profile.and_then(|item| read_string(item.get("summary")));
|
||||
|
||||
[
|
||||
Some(format!(
|
||||
"世界补充:{}",
|
||||
custom_world_name.unwrap_or_else(|| "无".to_string())
|
||||
)),
|
||||
custom_world_summary.map(|text| format!("世界摘要:{text}")),
|
||||
Some(format!("场景:{scene_name}")),
|
||||
Some(format!("场景描述:{scene_description}")),
|
||||
Some(format!("当前状态:{in_battle}")),
|
||||
Some(describe_stats("玩家", record)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn describe_monsters(monsters: &[Value]) -> String {
|
||||
if monsters.is_empty() {
|
||||
return "当前敌对目标:无。".to_string();
|
||||
}
|
||||
|
||||
let lines = monsters
|
||||
.iter()
|
||||
.take(4)
|
||||
.filter_map(|item| {
|
||||
let record = as_record(item)?;
|
||||
let name = read_string(record.get("name"))
|
||||
.or_else(|| read_string(record.get("npcName")))
|
||||
.or_else(|| read_string(record.get("id")))?;
|
||||
let hp = read_number(record.get("hp")).unwrap_or(0.0);
|
||||
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
|
||||
|
||||
Some(format!(
|
||||
"- {name}(生命 {}/{})",
|
||||
format_prompt_number(hp),
|
||||
format_prompt_number(max_hp)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if lines.is_empty() {
|
||||
"当前敌对目标:无。".to_string()
|
||||
} else {
|
||||
let mut result = vec!["当前敌对目标:".to_string()];
|
||||
result.extend(lines);
|
||||
result.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn read_string(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_number(value: Option<&Value>) -> Option<f64> {
|
||||
value
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|number| number.is_finite())
|
||||
}
|
||||
|
||||
fn read_bool(value: Option<&Value>) -> Option<bool> {
|
||||
value.and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
fn read_string_array(value: &Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| read_string(Some(item)))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
|
||||
value.as_object()
|
||||
}
|
||||
|
||||
fn format_prompt_number(value: f64) -> String {
|
||||
if value.fract() == 0.0 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user