453 lines
14 KiB
Rust
453 lines
14 KiB
Rust
use axum::{
|
|
Json,
|
|
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,
|
|
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> {
|
|
let npc_name = read_string_field(&payload.encounter, "npcName")
|
|
.or_else(|| read_string_field(&payload.encounter, "name"))
|
|
.unwrap_or_else(|| "对方".to_string());
|
|
let player_message = payload.player_message.trim();
|
|
if player_message.is_empty() {
|
|
return Err(runtime_chat_error_response(
|
|
&request_context,
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "runtime-chat",
|
|
"message": "playerMessage 不能为空",
|
|
})),
|
|
));
|
|
}
|
|
|
|
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": affinity_delta,
|
|
"affinityText": describe_affinity_shift(affinity_delta),
|
|
"suggestions": suggestions,
|
|
"pendingQuestOffer": null,
|
|
"chatDirective": build_completion_directive(payload.chat_directive.as_ref()),
|
|
});
|
|
|
|
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,
|
|
npc_initiates_conversation: bool,
|
|
) -> String {
|
|
// Rust API 尚未迁入旧 Node 的完整 LLM NPC 聊天编排前,先由后端提供稳定兜底,保证相遇与选项聊天链路不断。
|
|
if npc_initiates_conversation {
|
|
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
|
|
}
|
|
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
|
|
}
|
|
|
|
fn build_deterministic_chat_suggestions(npc_name: &str, player_message: &str) -> Vec<String> {
|
|
// 建议只承载玩家可点选的行动意图,不在 UI 里额外塞说明文案。
|
|
vec![
|
|
format!("继续询问{npc_name}的近况"),
|
|
"追问这里发生了什么".to_string(),
|
|
if player_message.contains('帮') || player_message.contains('忙') {
|
|
"请对方说清需要什么帮助".to_string()
|
|
} else {
|
|
"换个轻松的话题".to_string()
|
|
},
|
|
]
|
|
}
|
|
|
|
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": force_exit,
|
|
"closingMode": closing_mode,
|
|
})
|
|
}
|
|
|
|
fn read_string_field(value: &Value, field: &str) -> Option<String> {
|
|
value
|
|
.get(field)
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|text| !text.is_empty())
|
|
.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,
|
|
event: &str,
|
|
payload: &Value,
|
|
) -> Result<(), Response> {
|
|
let payload_text = serde_json::to_string(payload).map_err(|error| {
|
|
runtime_chat_error_response(
|
|
request_context,
|
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
"provider": "runtime-chat",
|
|
"message": format!("SSE payload 序列化失败:{error}"),
|
|
})),
|
|
)
|
|
})?;
|
|
body.push_str("event: ");
|
|
body.push_str(event);
|
|
body.push('\n');
|
|
body.push_str("data: ");
|
|
body.push_str(&payload_text);
|
|
body.push_str("\n\n");
|
|
Ok(())
|
|
}
|
|
|
|
fn build_event_stream_response(body: String) -> Response {
|
|
(
|
|
[
|
|
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn runtime_chat_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
|
error.into_response_with_context(Some(request_context))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn npc_chat_affinity_delta_keeps_node_keyword_rules() {
|
|
assert_eq!(
|
|
compute_npc_chat_affinity_delta("谢谢你愿意帮忙", "放心,我明白。", 0.0),
|
|
3
|
|
);
|
|
assert_eq!(
|
|
compute_npc_chat_affinity_delta("快说,别装。", "与你无关。", 2.0),
|
|
-3
|
|
);
|
|
assert_eq!(
|
|
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 0.0),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
compute_npc_chat_affinity_delta("这里怎么了", "我还在想。", 2.0),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn npc_chat_suggestion_parser_strips_list_markers() {
|
|
assert_eq!(
|
|
parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3),
|
|
vec!["继续问线索", "表明立场", "拉近关系"]
|
|
);
|
|
}
|
|
}
|