Refactor server-rs runtime and update related docs
This commit is contained in:
145
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
145
server-rs/crates/api-server/src/runtime_chat.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::Extension,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{http_error::AppError, request_context::RequestContext};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NpcChatTurnRequest {
|
||||
encounter: Value,
|
||||
player_message: String,
|
||||
#[serde(default)]
|
||||
npc_initiates_conversation: bool,
|
||||
#[serde(default)]
|
||||
chat_directive: Option<Value>,
|
||||
}
|
||||
|
||||
pub async fn stream_runtime_npc_chat_turn(
|
||||
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 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 complete_payload = json!({
|
||||
"npcReply": npc_reply,
|
||||
"affinityDelta": 0,
|
||||
"affinityText": "关系暂未变化",
|
||||
"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)?;
|
||||
Ok(build_event_stream_response(body))
|
||||
}
|
||||
|
||||
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_completion_directive(chat_directive: Option<&Value>) -> Value {
|
||||
let Some(directive) = chat_directive else {
|
||||
return Value::Null;
|
||||
};
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
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 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))
|
||||
}
|
||||
Reference in New Issue
Block a user