Prune obsolete docs and update navigation
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user