Files
Genarrative/server-rs/crates/api-server/src/runtime_chat.rs
2026-04-25 22:19:04 +08:00

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!["继续问线索", "表明立场", "拉近关系"]
);
}
}