674 lines
22 KiB
Rust
674 lines
22 KiB
Rust
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::story::StoryRuntimeSnapshotPayload as 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<String>,
|
|
#[serde(default)]
|
|
snapshot: Option<RuntimeStorySnapshotPayload>,
|
|
#[serde(default)]
|
|
world_type: String,
|
|
#[serde(default)]
|
|
player_character: Value,
|
|
#[serde(default)]
|
|
target_character: Value,
|
|
#[serde(default)]
|
|
story_history: Vec<Value>,
|
|
#[serde(default)]
|
|
context: Value,
|
|
#[serde(default)]
|
|
conversation_history: Vec<Value>,
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
snapshot: Option<RuntimeStorySnapshotPayload>,
|
|
#[serde(default)]
|
|
world_type: String,
|
|
#[serde(default)]
|
|
character: Value,
|
|
#[serde(default)]
|
|
encounter: Value,
|
|
#[serde(default)]
|
|
monsters: Vec<Value>,
|
|
#[serde(default)]
|
|
history: Vec<Value>,
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
snapshot: Option<RuntimeStorySnapshotPayload>,
|
|
#[serde(default)]
|
|
world_type: String,
|
|
#[serde(default)]
|
|
character: Value,
|
|
#[serde(default)]
|
|
encounter: Value,
|
|
#[serde(default)]
|
|
monsters: Vec<Value>,
|
|
#[serde(default)]
|
|
history: Vec<Value>,
|
|
#[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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
|
) -> Result<Json<Value>, 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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
|
) -> Result<Json<Value>, 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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(mut payload): Json<RuntimeCharacterChatRequest>,
|
|
) -> Result<Response, Response> {
|
|
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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(mut payload): Json<RuntimeNpcDialogueRequest>,
|
|
) -> Result<Response, Response> {
|
|
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<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(mut payload): Json<RuntimeNpcRecruitDialogueRequest>,
|
|
) -> Result<Response, Response> {
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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<Option<Value>, 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<Option<Value>, 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>,
|
|
) -> 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<platform_llm::LlmClient>,
|
|
enable_web_search: bool,
|
|
system_prompt: &'static str,
|
|
user_prompt: String,
|
|
fallback_text: String,
|
|
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
|
async_stream::stream! {
|
|
let Some(llm_client) = llm_client else {
|
|
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
|
|
yield Ok::<Event, Infallible>(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, Infallible>(Event::default().data(runtime_plain_text_sse_payload(output)));
|
|
}
|
|
Err(_) => {
|
|
yield Ok::<Event, Infallible>(Event::default().data(runtime_plain_text_sse_payload(fallback_text.as_str())));
|
|
}
|
|
}
|
|
|
|
yield Ok::<Event, Infallible>(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<String> {
|
|
value
|
|
.get(field)
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|text| !text.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|