This commit is contained in:
683
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
683
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
@@ -0,0 +1,683 @@
|
||||
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() && !payload.npc_initiates_conversation {
|
||||
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, function_suggestions, force_exit) = 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 force_exit = should_force_chat_exit(payload.chat_directive.as_ref())
|
||||
|| should_hostile_chat_breakoff_deterministically(
|
||||
player_message,
|
||||
payload.chat_directive.as_ref(),
|
||||
);
|
||||
let suggestions = if force_exit {
|
||||
Vec::new()
|
||||
} else {
|
||||
build_deterministic_chat_suggestions(npc_name.as_str(), player_message)
|
||||
};
|
||||
let function_suggestions = if force_exit {
|
||||
Vec::new()
|
||||
} else {
|
||||
build_fallback_function_suggestions(payload.chat_directive.as_ref())
|
||||
};
|
||||
let mut body = String::new();
|
||||
append_sse_event(
|
||||
&request_context,
|
||||
&mut body,
|
||||
"reply_delta",
|
||||
&json!({ "text": npc_reply }),
|
||||
)?;
|
||||
(
|
||||
body,
|
||||
npc_reply,
|
||||
suggestions,
|
||||
function_suggestions,
|
||||
force_exit,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let chatted_count = read_number_field(&payload.npc_state, "chattedCount").unwrap_or(0.0);
|
||||
let affinity_delta = if payload.npc_initiates_conversation {
|
||||
0
|
||||
} else {
|
||||
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,
|
||||
"functionSuggestions": function_suggestions,
|
||||
"pendingQuestOffer": null,
|
||||
"chatDirective": build_completion_directive(payload.chat_directive.as_ref(), force_exit),
|
||||
});
|
||||
|
||||
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>, Vec<Value>, bool)> {
|
||||
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(), Vec::new(), true));
|
||||
}
|
||||
|
||||
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 suggestion_text = llm_client
|
||||
.request_text(suggestion_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content)
|
||||
.unwrap_or_default();
|
||||
let (mut suggestions, mut function_suggestions, should_end_chat) =
|
||||
parse_npc_chat_suggestion_resolution(
|
||||
suggestion_text.as_str(),
|
||||
payload.chat_directive.as_ref(),
|
||||
);
|
||||
let force_exit = should_end_chat
|
||||
|| should_hostile_chat_breakoff_deterministically(
|
||||
payload.player_message.as_str(),
|
||||
payload.chat_directive.as_ref(),
|
||||
);
|
||||
|
||||
if force_exit {
|
||||
suggestions.clear();
|
||||
function_suggestions.clear();
|
||||
} else if suggestions.is_empty() {
|
||||
suggestions = build_fallback_npc_chat_suggestions(payload.player_message.as_str());
|
||||
}
|
||||
|
||||
Some((
|
||||
body,
|
||||
npc_reply,
|
||||
suggestions,
|
||||
function_suggestions,
|
||||
force_exit,
|
||||
))
|
||||
}
|
||||
|
||||
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_fallback_function_suggestions(chat_directive: Option<&Value>) -> Vec<Value> {
|
||||
read_function_options(chat_directive)
|
||||
.into_iter()
|
||||
.filter(|option| {
|
||||
read_string_field(option, "functionId")
|
||||
.as_deref()
|
||||
.is_some_and(|function_id| function_id != "npc_chat")
|
||||
})
|
||||
.take(2)
|
||||
.filter_map(|option| {
|
||||
let function_id = read_string_field(option, "functionId")?;
|
||||
let action_text = read_string_field(option, "actionText")?;
|
||||
Some(json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_completion_directive(chat_directive: Option<&Value>, force_exit: bool) -> 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 = force_exit
|
||||
|| closing_mode == "foreshadow_close"
|
||||
|| directive
|
||||
.get("forceExitAfterTurn")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let termination_reason = if force_exit {
|
||||
read_string_field(directive, "terminationReason")
|
||||
.filter(|value| value == "player_exit" || value == "hostile_breakoff")
|
||||
.or_else(|| {
|
||||
if is_hostile_model_chat(chat_directive) {
|
||||
Some("hostile_breakoff".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
json!({
|
||||
"turnLimit": directive.get("turnLimit").cloned().unwrap_or(Value::Null),
|
||||
"remainingTurns": directive.get("remainingTurns").cloned().unwrap_or(Value::Null),
|
||||
"forceExit": force_exit,
|
||||
"closingMode": if force_exit { "foreshadow_close" } else { closing_mode.as_str() },
|
||||
"terminationReason": termination_reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_npc_chat_suggestion_resolution(
|
||||
text: &str,
|
||||
chat_directive: Option<&Value>,
|
||||
) -> (Vec<String>, Vec<Value>, bool) {
|
||||
let normalized = text.trim();
|
||||
if normalized.is_empty() {
|
||||
return (
|
||||
Vec::new(),
|
||||
build_fallback_function_suggestions(chat_directive),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<Value>(normalized) {
|
||||
let should_end_chat = value
|
||||
.get("shouldEndChat")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
&& is_hostile_model_chat(chat_directive);
|
||||
let suggestions = value
|
||||
.get("suggestions")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let function_suggestions =
|
||||
parse_function_suggestions(value.get("functionSuggestions"), chat_directive);
|
||||
|
||||
return (suggestions, function_suggestions, should_end_chat);
|
||||
}
|
||||
|
||||
(
|
||||
parse_line_list_content(normalized, 3),
|
||||
build_fallback_function_suggestions(chat_directive),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_function_suggestions(value: Option<&Value>, chat_directive: Option<&Value>) -> Vec<Value> {
|
||||
let allowed_options = read_function_options(chat_directive);
|
||||
let allowed_ids = allowed_options
|
||||
.iter()
|
||||
.filter_map(|item| read_string_field(item, "functionId"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut used_ids: Vec<String> = Vec::new();
|
||||
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| {
|
||||
let function_id = read_string_field(item, "functionId")?;
|
||||
if function_id == "npc_chat" {
|
||||
return None;
|
||||
}
|
||||
if !allowed_ids.is_empty() && !allowed_ids.contains(&function_id) {
|
||||
return None;
|
||||
}
|
||||
if used_ids.contains(&function_id) {
|
||||
return None;
|
||||
}
|
||||
let fallback_text = allowed_options
|
||||
.iter()
|
||||
.find(|option| {
|
||||
read_string_field(option, "functionId").as_deref() == Some(function_id.as_str())
|
||||
})
|
||||
.and_then(|option| read_string_field(option, "actionText"));
|
||||
let action_text = read_string_field(item, "actionText")
|
||||
.or(fallback_text)
|
||||
.filter(|text| !text.trim().is_empty())?;
|
||||
used_ids.push(function_id.clone());
|
||||
Some(json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
}))
|
||||
})
|
||||
.take(3)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_function_options(chat_directive: Option<&Value>) -> Vec<&Value> {
|
||||
chat_directive
|
||||
.and_then(|directive| directive.get("functionOptions"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
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 read_bool_field(value: &Value, field: &str) -> Option<bool> {
|
||||
value.get(field).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
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")
|
||||
|| read_string_field(directive, "terminationReason").as_deref() == Some("player_exit")
|
||||
|| directive
|
||||
.get("forceExitAfterTurn")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool {
|
||||
let Some(directive) = chat_directive else {
|
||||
return false;
|
||||
};
|
||||
|
||||
read_string_field(directive, "terminationMode").as_deref() == Some("hostile_model")
|
||||
|| read_bool_field(directive, "isHostileChat").unwrap_or(false)
|
||||
}
|
||||
|
||||
fn should_hostile_chat_breakoff_deterministically(
|
||||
player_message: &str,
|
||||
chat_directive: Option<&Value>,
|
||||
) -> bool {
|
||||
if !is_hostile_model_chat(chat_directive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(directive) = chat_directive else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if read_string_field(directive, "terminationReason").as_deref() == Some("player_exit") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let hostile_break_words = [
|
||||
"动手",
|
||||
"开战",
|
||||
"拔刀",
|
||||
"杀",
|
||||
"滚",
|
||||
"闭嘴",
|
||||
"少废话",
|
||||
"别挡路",
|
||||
];
|
||||
count_keyword_matches(player_message, &hostile_break_words) > 0
|
||||
}
|
||||
|
||||
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_initiated_opening_keeps_neutral_affinity_delta() {
|
||||
// 首遇主动开场不是玩家发言结算,不能因为空 playerMessage 或占位文本触发好感变化。
|
||||
let npc_initiates_conversation = true;
|
||||
let player_message = "";
|
||||
let npc_reply = "你来了。先别急着走,我正有话想和你说。";
|
||||
let affinity_delta = if npc_initiates_conversation {
|
||||
0
|
||||
} else {
|
||||
compute_npc_chat_affinity_delta(player_message, npc_reply, 0.0)
|
||||
};
|
||||
|
||||
assert_eq!(affinity_delta, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_chat_suggestion_parser_strips_list_markers() {
|
||||
assert_eq!(
|
||||
parse_line_list_content("1. 继续问线索\n- 表明立场\n* 拉近关系\n4. 多余", 3),
|
||||
vec!["继续问线索", "表明立场", "拉近关系"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user