use axum::{ Json, extract::{Extension, State}, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, Sse}, }, }; use platform_llm::{LlmMessage, LlmTextRequest}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::runtime_story::RuntimeStorySnapshotPayload; use std::convert::Infallible; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::*, request_context::RequestContext, state::AppState, }; use module_runtime_story::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, normalize_required_string, read_array_field, read_field, read_runtime_session_id, }; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeCharacterChatRequest { #[serde(default)] session_id: Option, #[serde(default)] snapshot: Option, #[serde(default)] world_type: String, #[serde(default)] player_character: Value, #[serde(default)] target_character: Value, #[serde(default)] story_history: Vec, #[serde(default)] context: Value, #[serde(default)] conversation_history: Vec, #[serde(default)] conversation_summary: String, #[serde(default)] previous_summary: String, #[serde(default)] player_message: String, #[serde(default)] target_status: Value, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] snapshot: Option, #[serde(default)] world_type: String, #[serde(default)] character: Value, #[serde(default)] encounter: Value, #[serde(default)] monsters: Vec, #[serde(default)] history: Vec, #[serde(default)] context: Value, #[serde(default)] topic: String, #[serde(default)] result_summary: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeNpcRecruitDialogueRequest { #[serde(default)] session_id: Option, #[serde(default)] snapshot: Option, #[serde(default)] world_type: String, #[serde(default)] character: Value, #[serde(default)] encounter: Value, #[serde(default)] monsters: Vec, #[serde(default)] history: Vec, #[serde(default)] context: Value, #[serde(default)] invitation_text: String, #[serde(default)] recruit_summary: String, } pub async fn generate_runtime_character_chat_suggestions( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result, Response> { hydrate_character_chat_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let text = request_runtime_plain_text( &state, build_character_chat_suggestions_system_prompt(), build_character_chat_suggestions_user_prompt(CharacterChatPromptParams { world_type: payload.world_type.as_str(), player_character: &payload.player_character, target_character: &payload.target_character, story_history: &payload.story_history, context: &payload.context, conversation_history: &payload.conversation_history, conversation_summary: payload.conversation_summary.as_str(), previous_summary: payload.previous_summary.as_str(), player_message: payload.player_message.as_str(), target_status: &payload.target_status, }), Some(build_character_chat_suggestions_fallback( &payload.target_character, )), ) .await; Ok(json_success_body( Some(&request_context), json!({ "text": text }), )) } pub async fn generate_runtime_character_chat_summary( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result, Response> { hydrate_character_chat_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let text = request_runtime_plain_text( &state, build_character_chat_summary_system_prompt(), build_character_chat_summary_user_prompt(CharacterChatPromptParams { world_type: payload.world_type.as_str(), player_character: &payload.player_character, target_character: &payload.target_character, story_history: &payload.story_history, context: &payload.context, conversation_history: &payload.conversation_history, conversation_summary: payload.conversation_summary.as_str(), previous_summary: payload.previous_summary.as_str(), player_message: payload.player_message.as_str(), target_status: &payload.target_status, }), Some(build_character_chat_summary_fallback( &payload.target_character, &payload.conversation_history, payload.previous_summary.as_str(), )), ) .await; Ok(json_success_body( Some(&request_context), json!({ "text": text }), )) } pub async fn stream_runtime_character_chat_reply( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result { hydrate_character_chat_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let player_message = payload.player_message.trim().to_string(); if player_message.is_empty() { return Err(runtime_plain_chat_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "message": "playerMessage 不能为空", })), )); } let stream = stream_plain_text_response( state.llm_client().cloned(), state.config.rpg_llm_web_search_enabled, build_character_chat_reply_system_prompt(), build_character_chat_reply_user_prompt(CharacterChatPromptParams { world_type: payload.world_type.as_str(), player_character: &payload.player_character, target_character: &payload.target_character, story_history: &payload.story_history, context: &payload.context, conversation_history: &payload.conversation_history, conversation_summary: payload.conversation_summary.as_str(), previous_summary: payload.previous_summary.as_str(), player_message: payload.player_message.as_str(), target_status: &payload.target_status, }), build_character_chat_reply_fallback( &payload.target_character, payload.player_message.as_str(), payload.conversation_summary.as_str(), ), ); Ok(Sse::new(stream).into_response()) } pub async fn stream_runtime_npc_chat_dialogue( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result { hydrate_npc_dialogue_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let topic = payload.topic.trim().to_string(); if topic.is_empty() { return Err(runtime_plain_chat_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "message": "topic 不能为空", })), )); } let stream = stream_plain_text_response( state.llm_client().cloned(), state.config.rpg_llm_web_search_enabled, runtime_npc_dialogue_system_prompt(), { let npc_name = read_name_field(&payload.encounter, "npcName") .or_else(|| read_name_field(&payload.encounter, "name")) .unwrap_or_else(|| "对方".to_string()); build_runtime_npc_dialogue_user_prompt( npc_name.as_str(), RuntimeNpcDialoguePromptParams { world_type: payload.world_type.as_str(), character: &payload.character, encounter: &payload.encounter, monsters: payload.monsters.clone(), history: payload.history.clone(), context: payload.context.clone(), topic: payload.topic.as_str(), result_summary: payload.result_summary.as_str(), requested_option: Value::Null, available_options: Vec::new(), }, ) }, build_npc_chat_dialogue_fallback(&payload.encounter, payload.topic.as_str()), ); Ok(Sse::new(stream).into_response()) } pub async fn stream_runtime_npc_recruit_dialogue( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(mut payload): Json, ) -> Result { hydrate_npc_recruit_request_from_session( &state, &request_context, authenticated.claims().user_id().to_string(), &mut payload, ) .await?; let invitation_text = payload.invitation_text.trim().to_string(); if invitation_text.is_empty() { return Err(runtime_plain_chat_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "message": "invitationText 不能为空", })), )); } let npc_name = read_name_field(&payload.encounter, "npcName") .or_else(|| read_name_field(&payload.encounter, "name")) .unwrap_or_else(|| "对方".to_string()); let stream = stream_plain_text_response( state.llm_client().cloned(), state.config.rpg_llm_web_search_enabled, build_npc_recruit_dialogue_system_prompt(), build_npc_recruit_dialogue_user_prompt( npc_name.as_str(), NpcRecruitDialoguePromptParams { world_type: payload.world_type.as_str(), character: &payload.character, encounter: &payload.encounter, monsters: &payload.monsters, history: &payload.history, context: &payload.context, invitation_text: payload.invitation_text.as_str(), recruit_summary: payload.recruit_summary.as_str(), }, ), build_npc_recruit_dialogue_fallback(&payload.encounter), ); Ok(Sse::new(stream).into_response()) } async fn hydrate_character_chat_request_from_session( state: &AppState, request_context: &RequestContext, user_id: String, payload: &mut RuntimeCharacterChatRequest, ) -> Result<(), Response> { let Some(game_state) = resolve_runtime_chat_game_state( state, request_context, user_id, payload.session_id.as_deref(), payload.snapshot.as_ref(), ) .await? else { return Ok(()); }; payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.player_character = read_field(&game_state, "playerCharacter") .cloned() .unwrap_or(Value::Null); payload.story_history = read_array_field(&game_state, "storyHistory") .into_iter() .rev() .take(12) .collect::>() .into_iter() .rev() .cloned() .collect(); payload.context = build_runtime_story_prompt_context(&game_state, RuntimeStoryPromptContextExtras::default()); Ok(()) } async fn hydrate_npc_dialogue_request_from_session( state: &AppState, request_context: &RequestContext, user_id: String, payload: &mut RuntimeNpcDialogueRequest, ) -> Result<(), Response> { let Some(game_state) = resolve_runtime_chat_game_state( state, request_context, user_id, payload.session_id.as_deref(), payload.snapshot.as_ref(), ) .await? else { return Ok(()); }; payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.character = read_field(&game_state, "playerCharacter") .cloned() .unwrap_or(Value::Null); payload.encounter = read_field(&game_state, "currentEncounter") .cloned() .unwrap_or_else(|| payload.encounter.clone()); payload.monsters = read_array_field(&game_state, "sceneHostileNpcs") .into_iter() .cloned() .collect(); payload.history = read_array_field(&game_state, "storyHistory") .into_iter() .rev() .take(12) .collect::>() .into_iter() .rev() .cloned() .collect(); payload.context = build_runtime_story_prompt_context( &game_state, RuntimeStoryPromptContextExtras { last_function_id: Some("npc_chat".to_string()), ..RuntimeStoryPromptContextExtras::default() }, ); Ok(()) } async fn hydrate_npc_recruit_request_from_session( state: &AppState, request_context: &RequestContext, user_id: String, payload: &mut RuntimeNpcRecruitDialogueRequest, ) -> Result<(), Response> { let Some(game_state) = resolve_runtime_chat_game_state( state, request_context, user_id, payload.session_id.as_deref(), payload.snapshot.as_ref(), ) .await? else { return Ok(()); }; payload.world_type = current_world_type(&game_state).unwrap_or_default(); payload.character = read_field(&game_state, "playerCharacter") .cloned() .unwrap_or(Value::Null); payload.encounter = read_field(&game_state, "currentEncounter") .cloned() .unwrap_or_else(|| payload.encounter.clone()); payload.monsters = read_array_field(&game_state, "sceneHostileNpcs") .into_iter() .cloned() .collect(); payload.history = read_array_field(&game_state, "storyHistory") .into_iter() .rev() .take(12) .collect::>() .into_iter() .rev() .cloned() .collect(); payload.context = build_runtime_story_prompt_context( &game_state, RuntimeStoryPromptContextExtras { last_function_id: Some("npc_recruit".to_string()), ..RuntimeStoryPromptContextExtras::default() }, ); Ok(()) } async fn resolve_runtime_chat_game_state( state: &AppState, request_context: &RequestContext, user_id: String, session_id: Option<&str>, snapshot: Option<&RuntimeStorySnapshotPayload>, ) -> Result, Response> { let Some(session_id) = session_id.and_then(normalize_required_string) else { // 中文注释:未携带 sessionId 的旧调用仅保留兼容,后续正式运行态应全部走后端快照。 return Ok(None); }; if let Some(game_state) = resolve_request_snapshot_game_state(request_context, session_id.as_str(), snapshot)? { return Ok(Some(game_state)); } let record = state .get_runtime_snapshot_record(user_id) .await .map_err(|error| { runtime_plain_chat_error_response( request_context, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), })), ) })? .ok_or_else(|| { runtime_plain_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "运行时快照不存在,请先初始化并保存一次游戏", })), ) })?; let game_state = record.game_state; let snapshot_session_id = read_runtime_session_id(&game_state).unwrap_or_else(|| session_id.clone()); if snapshot_session_id != session_id { return Err(runtime_plain_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", "sessionId": session_id, "snapshotSessionId": snapshot_session_id, })), )); } Ok(Some(game_state)) } fn resolve_request_snapshot_game_state( request_context: &RequestContext, session_id: &str, snapshot: Option<&RuntimeStorySnapshotPayload>, ) -> Result, Response> { let Some(snapshot) = snapshot else { return Ok(None); }; if !snapshot.game_state.is_object() { return Err(runtime_plain_chat_error_response( request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "runtime-chat", "field": "snapshot.gameState", "message": "snapshot.gameState 必须是 JSON object", })), )); } let snapshot_session_id = read_runtime_session_id(&snapshot.game_state).unwrap_or_else(|| session_id.to_string()); if snapshot_session_id != session_id { return Err(runtime_plain_chat_error_response( request_context, AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "runtime-chat", "message": "请求的运行时会话与服务端快照不一致,请重新进入游戏", "sessionId": session_id, "snapshotSessionId": snapshot_session_id, })), )); } // 中文注释:临时运行态聊天只读取请求 snapshot 构造上下文,不把它写回 runtime_snapshot。 Ok(Some(snapshot.game_state.clone())) } async fn request_runtime_plain_text( state: &AppState, system_prompt: &'static str, user_prompt: String, fallback_text: Option, ) -> String { let Some(llm_client) = state.llm_client() else { return fallback_text.unwrap_or_default(); }; let mut request = LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]); request.max_tokens = Some(400); request.enable_web_search = state.config.rpg_llm_web_search_enabled; request.model = Some(RPG_STORY_LLM_MODEL.to_string()); llm_client .request_text(request) .await .ok() .map(|response| response.content.trim().to_string()) .filter(|text| !text.is_empty()) .or(fallback_text) .unwrap_or_default() } fn stream_plain_text_response<'a>( llm_client: Option, enable_web_search: bool, system_prompt: &'static str, user_prompt: String, fallback_text: String, ) -> impl tokio_stream::Stream> { async_stream::stream! { let Some(llm_client) = llm_client else { yield Ok::(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str()))); yield Ok::(Event::default().data("[DONE]")); return; }; let mut request = LlmTextRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]); request.max_tokens = Some(700); request.enable_web_search = enable_web_search; request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let response = llm_client .stream_text(request, |_| {}) .await; match response { Ok(response) => { let final_text = response.content.trim(); let output = if final_text.is_empty() { fallback_text.as_str() } else { final_text }; yield Ok::(Event::default().data(runtime_plain_text_sse_payload(output))); } Err(_) => { yield Ok::(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str()))); } } yield Ok::(Event::default().data("[DONE]")); } } fn runtime_plain_text_sse_payload(text: &str) -> String { json!({ "choices": [{ "delta": { "content": text, } }] }) .to_string() } fn runtime_plain_chat_error_response( request_context: &RequestContext, error: AppError, ) -> Response { error.into_response_with_context(Some(request_context)) } fn read_name_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) }