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, #[serde(default)] player: Option, encounter: Value, #[serde(default)] monsters: Vec, #[serde(default)] history: Vec, #[serde(default)] context: Value, #[serde(default)] conversation_history: Vec, #[serde(default)] dialogue: Vec, #[serde(default)] combat_context: Option, player_message: String, #[serde(default)] npc_state: Value, #[serde(default)] npc_initiates_conversation: bool, #[serde(default)] chat_directive: Option, } pub async fn stream_runtime_npc_chat_turn( State(state): State, Extension(request_context): Extension, Json(payload): Json, ) -> Result { 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)> { 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 { // 建议只承载玩家可点选的行动意图,不在 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 { let topic = player_message.trim().chars().take(8).collect::(); 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 { 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 { 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 { 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 { 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!["继续问线索", "表明立场", "拉近关系"] ); } }