This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -68,7 +68,7 @@ use crate::{
proxy_generated_animations, proxy_generated_big_fish_assets,
proxy_generated_character_drafts, proxy_generated_characters,
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
proxy_generated_qwen_sprites,
proxy_generated_puzzle_assets, proxy_generated_qwen_sprites,
},
llm::proxy_llm_chat_completions,
login_options::auth_login_options,
@@ -188,6 +188,10 @@ pub fn build_router(state: AppState) -> Router {
"/generated-big-fish-assets/{*path}",
get(proxy_generated_big_fish_assets),
)
.route(
"/generated-puzzle-assets/{*path}",
get(proxy_generated_puzzle_assets),
)
.route(
"/generated-custom-world-scenes/{*path}",
get(proxy_generated_custom_world_scenes),

View File

@@ -27,6 +27,13 @@ use crate::{
state::AppState,
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
];
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -118,11 +125,11 @@ pub async fn get_asset_history(
Query(query): Query<AssetHistoryQuery>,
) -> Result<Json<Value>, AppError> {
let asset_kind = query.kind.trim().to_string();
if asset_kind != "character_visual" && asset_kind != "scene_image" {
if !is_supported_asset_history_kind(asset_kind.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "kind",
"message": "历史素材类型只支持 character_visual 或 scene_image",
"message": supported_asset_history_kind_message(),
})),
);
}
@@ -288,6 +295,17 @@ fn format_asset_owner_label(owner_user_id: Option<&str>) -> String {
format!("账号 {owner_user_id}")
}
fn is_supported_asset_history_kind(asset_kind: &str) -> bool {
SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind)
}
fn supported_asset_history_kind_message() -> String {
format!(
"历史素材类型只支持 {}",
SUPPORTED_ASSET_HISTORY_KINDS.join("")
)
}
async fn build_confirm_asset_object_upsert_input(
oss_client: &platform_oss::OssClient,
payload: ConfirmAssetObjectRequest,
@@ -457,6 +475,22 @@ mod tests {
type HmacSha1 = Hmac<Sha1>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
}
#[test]
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
);
}
#[tokio::test]
async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -238,6 +238,7 @@ pub async fn submit_big_fish_message(
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
draft_sink.persist_visible_text_async(text);
@@ -350,6 +351,7 @@ pub async fn stream_big_fish_message(
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
let _ = reply_tx.send(text.to_string());

View File

@@ -19,6 +19,7 @@ pub(crate) struct BigFishAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a BigFishSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
@@ -122,6 +123,7 @@ where
request.llm_client,
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",

View File

@@ -81,6 +81,7 @@ pub struct AppConfig {
pub llm_max_retries: u32,
pub llm_retry_backoff_ms: u64,
pub rpg_llm_web_search_enabled: bool,
pub creation_agent_llm_web_search_enabled: bool,
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_scene_image_model: String,
@@ -170,6 +171,7 @@ impl Default for AppConfig {
llm_max_retries: DEFAULT_MAX_RETRIES,
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
rpg_llm_web_search_enabled: true,
creation_agent_llm_web_search_enabled: true,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
@@ -475,6 +477,13 @@ impl AppConfig {
config.rpg_llm_web_search_enabled = rpg_llm_web_search_enabled;
}
if let Some(creation_agent_llm_web_search_enabled) = read_first_bool_env(&[
"GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED",
"CREATION_AGENT_LLM_WEB_SEARCH_ENABLED",
]) {
config.creation_agent_llm_web_search_enabled = creation_agent_llm_web_search_enabled;
}
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
config.dashscope_base_url = dashscope_base_url;
}
@@ -843,4 +852,24 @@ mod tests {
std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED");
}
}
#[test]
fn from_env_reads_creation_agent_llm_web_search_switch() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false");
}
let config = AppConfig::from_env();
assert!(!config.creation_agent_llm_web_search_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
}
}
}

View File

@@ -21,6 +21,7 @@ pub(crate) async fn stream_creation_agent_json_turn<F, E>(
llm_client: Option<&LlmClient>,
system_prompt: String,
user_prompt: impl Into<String>,
enable_web_search: bool,
messages: CreationAgentLlmTurnErrorMessages<'_>,
mut on_reply_update: F,
build_error: impl Fn(String) -> E,
@@ -33,10 +34,7 @@ where
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt.into()),
]),
build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
@@ -61,6 +59,19 @@ where
Ok(CreationAgentJsonTurnOutput { parsed })
}
fn build_creation_agent_llm_request(
system_prompt: String,
user_prompt: String,
enable_web_search: bool,
) -> LlmTextRequest {
// 创作 Agent 是否联网由 api-server 配置集中传入,避免各玩法各自散落默认值。
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_web_search(enable_web_search)
}
pub(crate) async fn request_creation_agent_json_turn<E>(
llm_client: &LlmClient,
system_prompt: String,
@@ -149,7 +160,10 @@ fn read_reply_text(parsed: &JsonValue) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{extract_reply_text_from_partial_json, parse_json_response_text};
use super::{
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
parse_json_response_text,
};
#[test]
fn extracts_reply_text_from_partial_json_with_chinese_text() {
@@ -167,4 +181,13 @@ mod tests {
assert_eq!(parsed["replyText"].as_str(), Some(""));
}
#[test]
fn builds_stream_request_with_web_search_when_enabled() {
let request =
build_creation_agent_llm_request("系统提示".to_string(), "用户提示".to_string(), true);
assert!(request.enable_web_search);
assert_eq!(request.messages.len(), 2);
}
}

View File

@@ -759,6 +759,7 @@ pub async fn submit_custom_world_agent_message(
session: &session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
focus_card_id: payload.focus_card_id.clone(),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
draft_sink.persist_visible_text_async(text);
@@ -910,6 +911,7 @@ pub async fn stream_custom_world_agent_message(
session: &session,
quick_fill_requested,
focus_card_id,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
let _ = reply_tx.send(text.to_string());

View File

@@ -28,6 +28,7 @@ pub(crate) struct CustomWorldAgentTurnRequest<'a> {
pub session: &'a CustomWorldAgentSessionRecord,
pub quick_fill_requested: bool,
pub focus_card_id: Option<String>,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
@@ -214,6 +215,7 @@ where
request.session.progress_percent,
request.quick_fill_requested,
&current_anchor_content,
request.enable_web_search,
on_reply_update,
)
.await?;
@@ -476,6 +478,7 @@ async fn stream_single_turn<F>(
progress_percent: u32,
quick_fill_requested: bool,
current_anchor_content: &EightAnchorContent,
enable_web_search: bool,
on_reply_update: F,
) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
where
@@ -505,6 +508,7 @@ where
Some(llm_client),
prompt,
"请按约定输出这一轮的 JSON。",
enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "这一轮设定生成失败,请稍后重试。",

View File

@@ -39,6 +39,13 @@ pub async fn proxy_generated_big_fish_assets(
proxy_legacy_generated_asset(state, LegacyAssetPrefix::BigFishAssets, path).await
}
pub async fn proxy_generated_puzzle_assets(
State(state): State<AppState>,
Path(path): Path<String>,
) -> Response {
proxy_legacy_generated_asset(state, LegacyAssetPrefix::PuzzleAssets, path).await
}
pub async fn proxy_generated_custom_world_scenes(
State(state): State<AppState>,
Path(path): Path<String>,

View File

@@ -46,7 +46,6 @@ mod request_context;
mod response_headers;
mod runtime_browse_history;
mod runtime_chat;
mod runtime_chat_prompt;
mod runtime_inventory;
mod runtime_profile;
mod runtime_save;

View File

@@ -2,5 +2,6 @@ pub(crate) mod agent_chat;
pub(crate) mod character_animation;
pub(crate) mod character_visual;
pub(crate) mod foundation_draft;
pub(crate) mod puzzle_image;
pub(crate) mod runtime_chat;
pub(crate) mod scene_background;

View File

@@ -0,0 +1,44 @@
/// 拼图图片生成的默认反向提示词。
///
/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排
/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。
pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
/// 根据拼图关卡名和创作者输入构造最终发给图片模型的提示词。
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!(
concat!(
"请生成一张适合正方形拼图关卡的高清插画。",
"关卡名:{level_name}。",
"画面主体:{prompt}。",
"画面要求1:1 正方形画布,适配 3x3 或 4x4 拼图切块,",
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
"避免文字、水印、边框和 UI 元素。"
),
level_name = level_name,
prompt = prompt,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() {
let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索");
assert!(prompt.contains("雨夜神庙"));
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
assert!(prompt.contains("正方形拼图关卡"));
assert!(prompt.contains("3x3 或 4x4"));
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
}
#[test]
fn default_negative_prompt_blocks_text_and_low_quality_assets() {
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度"));
assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印"));
}
}

View File

@@ -112,3 +112,726 @@ pub(crate) fn build_runtime_reasoned_story_user_prompt(
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}"
)
}
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
只输出 JSON不要输出 Markdown 或解释。
JSON 结构:
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub dialogue: &'a [Value],
pub combat_context: Option<&'a Value>,
pub player_message: &'a str,
pub npc_state: &'a Value,
pub npc_initiates_conversation: bool,
pub chat_directive: Option<&'a Value>,
}
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
let context = as_record(payload.context);
let npc_state = as_record(payload.npc_state);
let chat_directive = payload.chat_directive.and_then(as_record);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let opening_camp_background =
context.and_then(|record| read_string(record.get("openingCampBackground")));
let opening_camp_dialogue =
context.and_then(|record| read_string(record.get("openingCampDialogue")));
let allowed_topics = context
.and_then(|record| record.get("encounterAllowedTopics"))
.map(read_string_array)
.unwrap_or_default();
let blocked_topics = context
.and_then(|record| record.get("encounterBlockedTopics"))
.map(read_string_array)
.unwrap_or_default();
let is_first_meaningful_contact = context
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
.unwrap_or(false);
let affinity = npc_state
.and_then(|record| read_number(record.get("affinity")))
.unwrap_or(0.0);
let chatted_count = npc_state
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
let turn_limit = chat_directive
.and_then(|record| read_number(record.get("turnLimit")))
.unwrap_or(0.0)
.max(0.0);
let remaining_turns = chat_directive
.and_then(|record| read_number(record.get("remainingTurns")))
.unwrap_or(0.0)
.max(0.0);
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
.unwrap_or(false);
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
as_record(item)
.and_then(|turn| read_string(turn.get("speaker")))
.is_some_and(|speaker| speaker == "npc")
});
let is_first_npc_spoken_turn =
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
let first_contact_relation_stance = describe_first_contact_relation_stance(
context.and_then(|record| record.get("firstContactRelationStance")),
);
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
if is_first_npc_spoken_turn {
Some(format!(
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
encounter.npc_name
))
} else {
None
},
if is_first_npc_spoken_turn {
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
} else {
None
},
if is_first_npc_spoken_turn {
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some(format!(
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
encounter.npc_name
))
} else {
None
},
if allowed_topics.is_empty() {
None
} else {
Some(format!("当前更适合先谈:{}", allowed_topics.join("")))
},
if blocked_topics.is_empty() {
None
} else {
Some(format!("当前避免直接说破:{}", blocked_topics.join("")))
},
if is_limited_negative_affinity_chat {
Some(format!(
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
format_prompt_number(turn_limit)
))
} else {
None
},
if is_hostile_model_chat {
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
} else {
None
},
if is_hostile_model_chat {
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
format_prompt_number(remaining_turns)
))
} else {
None
},
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
},
if payload.npc_initiates_conversation {
Some(format!(
"现在请只写 {} 主动开口时会说的话。",
encounter.npc_name
))
} else {
Some(format!(
"现在请只写 {} 这一轮会回复玩家的话。",
encounter.npc_name
))
},
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload: &NpcChatTurnPromptInput<'_>,
npc_reply: &str,
) -> String {
let encounter = describe_encounter(payload.encounter);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
let chat_directive = payload.chat_directive.and_then(as_record);
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
.filter(|text| !text.trim().is_empty());
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
function_options_block,
if payload.npc_initiates_conversation {
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message))
},
Some(format!("NPC 刚刚回复:{npc_reply}")),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
None
},
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
Some("只输出 JSON{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_deterministic_npc_reply(
npc_name: &str,
player_message: &str,
npc_initiates_conversation: bool,
) -> String {
// LLM 不可用时仍由后端返回稳定中文对白,保证相遇和点击聊天链路不断。
if npc_initiates_conversation {
return format!("{npc_name}看向你,先开口说道:“你来了。先别急着走,我正有话想和你说。”");
}
format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”")
}
pub(crate) 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()
},
]
}
pub(crate) 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(),
]
}
pub(crate) 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 describe_function_options(value: &Value) -> String {
let lines = value
.as_array()
.map(|items| {
items
.iter()
.take(8)
.filter_map(|item| {
let record = as_record(item)?;
let function_id = read_string(record.get("functionId"))?;
let action_text = read_string(record.get("actionText"))?;
let detail_text = read_string(record.get("detailText"));
let action = read_string(record.get("action"));
Some(format!(
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
action.unwrap_or_else(|| "unknown".to_string()),
detail_text.unwrap_or_else(|| "".to_string()),
))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if lines.is_empty() {
return String::new();
}
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions".to_string()];
result.extend(lines);
result.join("\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
[
format!("世界:{}", describe_world(payload.world_type)),
describe_scene_context(payload.context),
describe_character("玩家 / ", payload.character),
encounter.block,
describe_monsters(payload.monsters),
describe_story_history(payload.history),
]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
struct EncounterDescription {
npc_name: String,
block: String,
}
fn describe_encounter(encounter: &Value) -> EncounterDescription {
let record = as_record(encounter);
let npc_name = record
.and_then(|item| read_string(item.get("npcName")))
.unwrap_or_else(|| "眼前角色".to_string());
let context_text = record
.and_then(|item| read_string(item.get("context")))
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
EncounterDescription {
npc_name: npc_name.clone(),
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
}
}
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
match value.and_then(|item| item.as_str()).map(str::trim) {
Some("guarded") => "戒备试探".to_string(),
Some("neutral") => "正常交流但仍不熟".to_string(),
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
_ => "第一次真正接触".to_string(),
}
}
fn describe_world(world_type: &str) -> String {
match world_type {
"WUXIA" => "边城模板".to_string(),
"XIANXIA" => "灵潮模板".to_string(),
"CUSTOM" => "自定义世界".to_string(),
value if !value.trim().is_empty() => value.to_string(),
_ => "未知世界".to_string(),
}
}
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
let hp = record
.and_then(|item| read_number(item.get("hp")))
.unwrap_or(0.0);
let max_hp = record
.and_then(|item| read_number(item.get("maxHp")))
.unwrap_or(hp)
.max(1.0);
let mana = record
.and_then(|item| read_number(item.get("mana")))
.unwrap_or(0.0);
let max_mana = record
.and_then(|item| read_number(item.get("maxMana")))
.unwrap_or(mana)
.max(1.0);
format!(
"{label}生命 {}/{},灵力 {}/{}",
format_prompt_number(hp),
format_prompt_number(max_hp),
format_prompt_number(mana),
format_prompt_number(max_mana)
)
}
fn describe_character(label: &str, value: &Value) -> String {
let record = as_record(value);
let name = record
.and_then(|item| read_string(item.get("name")))
.unwrap_or_else(|| "未知角色".to_string());
let title = record
.and_then(|item| read_string(item.get("title")))
.unwrap_or_else(|| "未知称号".to_string());
let description = record
.and_then(|item| read_string(item.get("description")))
.unwrap_or_else(|| "暂无额外描述".to_string());
let personality = record
.and_then(|item| read_string(item.get("personality")))
.unwrap_or_else(|| "性格信息未显式提供".to_string());
[
format!("{label}姓名:{name}"),
format!("{label}称号:{title}"),
format!("{label}描述:{description}"),
format!("{label}性格:{personality}"),
]
.join("\n")
}
fn describe_story_history(history: &[Value]) -> String {
if history.is_empty() {
return "近期剧情:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
.collect::<Vec<_>>();
if lines.is_empty() {
"近期剧情:暂无。".to_string()
} else {
let mut result = vec!["近期剧情:".to_string()];
result.extend(lines.into_iter().map(|line| format!("- {line}")));
result.join("\n")
}
}
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
if history.is_empty() {
return "当前聊天记录:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker = read_string(record.get("speaker"));
let speaker_name = read_string(record.get("speakerName"));
let text = read_string(record.get("text"))?;
match speaker.as_deref() {
Some("player") => Some(format!("- 玩家:{text}")),
Some("npc") => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| npc_name.to_string())
)),
Some("system") => Some(format!("- 系统提示:{text}")),
_ => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| "同伴".to_string())
)),
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前聊天记录:暂无。".to_string()
} else {
let mut result = vec!["当前聊天记录:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
let record = as_record(combat_context)?;
let summary = read_string(record.get("summary"));
let battle_outcome = read_string(record.get("battleOutcome"));
let log_lines = record
.get("logLines")
.map(read_string_array)
.unwrap_or_default()
.into_iter()
.take(6)
.collect::<Vec<_>>();
if summary.is_none() && log_lines.is_empty() {
return None;
}
let outcome_text = match battle_outcome.as_deref() {
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
_ => None,
};
let mut lines = vec!["刚刚结束的交锋:".to_string()];
if let Some(text) = outcome_text {
lines.push(text);
}
if let Some(text) = summary {
lines.push(format!("- 结果摘要:{text}"));
}
if !log_lines.is_empty() {
lines.push("- 战斗日志:".to_string());
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
}
Some(lines.join("\n"))
}
fn describe_scene_context(context: &Value) -> String {
let record = as_record(context);
let scene_name = record
.and_then(|item| read_string(item.get("sceneName")))
.unwrap_or_else(|| "当前区域".to_string());
let scene_description = record
.and_then(|item| read_string(item.get("sceneDescription")))
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
let in_battle = if record
.and_then(|item| read_bool(item.get("inBattle")))
.unwrap_or(false)
{
"战斗中"
} else {
"非战斗"
};
let custom_world_profile = record
.and_then(|item| item.get("customWorldProfile"))
.and_then(as_record);
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
let custom_world_summary =
custom_world_profile.and_then(|item| read_string(item.get("summary")));
[
Some(format!(
"世界补充:{}",
custom_world_name.unwrap_or_else(|| "".to_string())
)),
custom_world_summary.map(|text| format!("世界摘要:{text}")),
Some(format!("场景:{scene_name}")),
Some(format!("场景描述:{scene_description}")),
Some(format!("当前状态:{in_battle}")),
Some(describe_stats("玩家", record)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n")
}
fn describe_monsters(monsters: &[Value]) -> String {
if monsters.is_empty() {
return "当前敌对目标:无。".to_string();
}
let lines = monsters
.iter()
.take(4)
.filter_map(|item| {
let record = as_record(item)?;
let name = read_string(record.get("name"))
.or_else(|| read_string(record.get("npcName")))
.or_else(|| read_string(record.get("id")))?;
let hp = read_number(record.get("hp")).unwrap_or(0.0);
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
Some(format!(
"- {name}(生命 {}/{})",
format_prompt_number(hp),
format_prompt_number(max_hp)
))
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前敌对目标:无。".to_string()
} else {
let mut result = vec!["当前敌对目标:".to_string()];
result.extend(lines);
result.join("\n")
}
}
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_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number(value: Option<&Value>) -> Option<f64> {
value
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn read_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| read_string(Some(item)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn format_prompt_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
value.to_string()
}
}

View File

@@ -12,12 +12,16 @@ use axum::{
sse::{Event, Sse},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::PuzzleGeneratedImageCandidate;
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde_json::{Map, Value, json};
use shared_contracts::{
puzzle_agent::{
@@ -64,6 +68,7 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
@@ -78,8 +83,6 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
pub async fn create_puzzle_agent_session(
State(state): State<AppState>,
@@ -216,6 +219,7 @@ pub async fn submit_puzzle_agent_message(
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
|_| {},
)
@@ -320,6 +324,7 @@ pub async fn stream_puzzle_agent_message(
llm_client: state.llm_client(),
session: &session,
quick_fill_requested,
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
},
move |text| {
let _ = reply_tx.send(text.to_string());
@@ -447,7 +452,7 @@ pub async fn execute_puzzle_agent_action(
(
"compile_puzzle_draft",
"完整拼图草稿",
"已编译草稿、生成候选图并应用正式图",
"已编译草稿、生成拼图图片并应用正式图。",
session,
)
}
@@ -468,7 +473,8 @@ pub async fn execute_puzzle_agent_action(
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| draft.summary.clone());
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_count = 1;
let candidate_start_index = draft.candidates.len();
let candidates = generate_puzzle_image_candidates(
&state,
@@ -476,6 +482,7 @@ pub async fn execute_puzzle_agent_action(
&session.session_id,
&draft.level_name,
&prompt,
payload.reference_image_src.as_deref(),
candidate_count,
candidate_start_index,
)
@@ -521,8 +528,8 @@ pub async fn execute_puzzle_agent_action(
};
(
"generate_puzzle_images",
"候选图生成",
"已生成 2 张候选拼图图",
"拼图图片生成",
"已生成并替换当前拼图图",
session,
)
}
@@ -1296,6 +1303,7 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: Vec::new(),
}
}
@@ -1381,6 +1389,10 @@ fn map_puzzle_runtime_level_response(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: 0,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}
}
@@ -1476,7 +1488,8 @@ async fn compile_puzzle_draft_with_initial_cover(
&compiled_session.session_id,
&draft.level_name,
&draft.summary,
2,
None,
1,
draft.candidates.len(),
)
.await
@@ -1619,23 +1632,53 @@ async fn generate_puzzle_image_candidates(
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
let count = candidate_count.clamp(1, 2);
let count = candidate_count.clamp(1, 1);
let settings =
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
let http_client = build_puzzle_dashscope_http_client(&settings)
.map_err(|error| error.message().to_string())?;
let generated = create_puzzle_text_to_image_generation(
&http_client,
&settings,
build_puzzle_image_prompt(level_name, prompt).as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
)
.await
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(source) => Some(
resolve_puzzle_reference_image_as_data_url(state, &http_client, source)
.await
.map_err(|error| error.message().to_string())?,
),
None => None,
};
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与 DashScope 图生图都必须停留在 api-server。
let generated = match reference_image.as_deref() {
Some(reference_image) => {
create_puzzle_image_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
reference_image,
)
.await
}
None => {
create_puzzle_text_to_image_generation(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
"1024*1024",
count,
)
.await
}
}
.map_err(|error| error.message().to_string())?;
let mut items = Vec::with_capacity(generated.images.len());
@@ -1661,9 +1704,10 @@ async fn generate_puzzle_image_candidates(
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: Some(prompt.to_string()),
actual_prompt: Some(actual_prompt.clone()),
source_type: "generated".to_string(),
selected: candidate_start_index == 0 && index == 0,
// 单图生成结果总是直接成为当前正式图。
selected: index == 0,
});
}
@@ -1740,7 +1784,8 @@ async fn build_local_next_puzzle_run(
&session.session_id,
&draft.level_name,
&draft.summary,
2,
None,
1,
draft.candidates.len(),
)
.await
@@ -1946,6 +1991,7 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,
reference_image_model: String,
request_timeout_ms: u64,
}
@@ -1960,6 +2006,11 @@ struct PuzzleDownloadedImage {
bytes: Vec<u8>,
}
struct ParsedPuzzleImageDataUrl {
mime_type: String,
bytes: Vec<u8>,
}
struct GeneratedPuzzleAssetResponse {
image_src: String,
asset_id: String,
@@ -1994,6 +2045,7 @@ fn require_puzzle_dashscope_settings(
Ok(PuzzleDashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
reference_image_model: state.config.dashscope_reference_image_model.clone(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
@@ -2036,7 +2088,7 @@ async fn create_puzzle_text_to_image_generation(
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(candidate_count.clamp(1, 2))),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
@@ -2127,7 +2179,7 @@ async fn create_puzzle_text_to_image_generation(
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 2) as usize)
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
@@ -2150,6 +2202,270 @@ async fn create_puzzle_text_to_image_generation(
)
}
async fn resolve_puzzle_reference_image_as_data_url(
state: &AppState,
http_client: &reqwest::Client,
source: &str,
) -> Result<String, AppError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图不能为空。",
})),
);
}
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
return Ok(format!(
"data:{};base64,{}",
parsed.mime_type,
BASE64_STANDARD.encode(parsed.bytes)
));
}
if !trimmed.starts_with('/') {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
})),
);
}
let object_key = trimmed.trim_start_matches('/');
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "参考图当前只支持 /generated-* 旧路径。",
})),
);
}
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: object_key.to_string(),
expire_seconds: Some(60),
})
.map_err(map_puzzle_asset_oss_error)?;
let response = http_client
.get(signed.signed_url)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取参考图失败,状态码:{status}"),
"objectKey": object_key,
})),
);
}
if body.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": "读取参考图失败:对象内容为空",
"objectKey": object_key,
})),
);
}
Ok(format!(
"data:{};base64,{}",
content_type,
BASE64_STANDARD.encode(body)
))
}
async fn create_puzzle_image_to_image_generation(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut content = vec![json!({ "image": reference_image })];
content.push(json!({ "text": prompt }));
let response = http_client
.post(format!(
"{}/services/aigc/multimodal-generation/generation",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.reference_image_model.as_str(),
"input": {
"messages": [
{
"role": "user",
"content": content,
}
],
},
"parameters": {
"n": candidate_count.clamp(1, 1),
"size": size,
"negative_prompt": negative_prompt,
"prompt_extend": true,
"watermark": false,
},
}))
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}"))
})?;
if !status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
response_text.as_str(),
"创建拼图参考图生成任务失败",
));
}
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?;
let image_urls = extract_puzzle_image_urls(&payload);
if image_urls.is_empty() {
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图参考图生成未返回 task_id 或图片地址",
}))
})?;
return wait_puzzle_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
"拼图参考图生成任务失败",
)
.await;
}
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
Ok(PuzzleGeneratedImages {
task_id: format!("puzzle-ref-{}", current_utc_micros()),
images,
})
}
async fn wait_puzzle_generated_images(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
task_id: &str,
candidate_count: u32,
failure_message: &str,
) -> Result<PuzzleGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
})?;
if !poll_status.is_success() {
return Err(map_puzzle_dashscope_upstream_error(
poll_text.as_str(),
"查询拼图图片生成任务失败",
));
}
let poll_payload =
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
.unwrap_or_default()
.trim()
.to_string();
if task_status == "SUCCEEDED" {
let image_urls = extract_puzzle_image_urls(&poll_payload);
if image_urls.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成成功但未返回图片地址",
})),
);
}
let mut images = Vec::with_capacity(image_urls.len());
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 1) as usize)
{
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
return Ok(PuzzleGeneratedImages {
task_id: task_id.to_string(),
images,
});
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_puzzle_dashscope_upstream_error(
poll_text.as_str(),
failure_message,
));
}
sleep(Duration::from_secs(2)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成超时或未返回图片地址",
})),
)
}
async fn download_puzzle_remote_image(
http_client: &reqwest::Client,
image_url: &str,
@@ -2278,12 +2594,6 @@ async fn persist_puzzle_generated_asset(
})
}
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!(
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
)
}
fn build_puzzle_asset_metadata(
owner_user_id: &str,
session_id: &str,
@@ -2307,6 +2617,46 @@ fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<V
})
}
fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
let body = value.strip_prefix("data:")?;
let (mime_type, data) = body.split_once(";base64,")?;
if !mime_type.starts_with("image/") {
return None;
}
let bytes = decode_puzzle_base64(data)?;
Some(ParsedPuzzleImageDataUrl {
mime_type: mime_type.to_string(),
bytes,
})
}
fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
let cleaned = value.trim().replace(char::is_whitespace, "");
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
let mut buffer = 0u32;
let mut bits = 0u8;
for byte in cleaned.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' => break,
_ => return None,
} as u32;
buffer = (buffer << 6) | value;
bits += 6;
while bits >= 8 {
bits -= 8;
output.push(((buffer >> bits) & 0xFF) as u8);
}
}
Some(output)
}
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
find_first_puzzle_string_by_key(payload, "task_id")
}

View File

@@ -19,6 +19,7 @@ pub(crate) struct PuzzleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a PuzzleAgentSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
@@ -128,6 +129,7 @@ where
request.llm_client,
format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "拼图聊天生成失败,请稍后重试。",

View File

@@ -14,12 +14,14 @@ use std::convert::Infallible;
use crate::{
http_error::AppError,
request_context::RequestContext,
runtime_chat_prompt::{
prompt::runtime_chat::{
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
NpcChatTurnPromptInput, build_npc_chat_turn_reply_prompt,
NpcChatTurnPromptInput, build_deterministic_chat_suggestions,
build_deterministic_npc_reply, build_fallback_function_suggestions,
build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt,
build_npc_chat_turn_suggestion_prompt,
},
request_context::RequestContext,
state::AppState,
};
@@ -256,66 +258,6 @@ where
Some((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;

View File

@@ -1,644 +0,0 @@
use serde_json::Value;
pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。"#;
pub(crate) const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT: &str = r#"你要为 RPG NPC 聊天生成下一步候选,并判断敌对聊天是否已经收束。
只输出 JSON不要输出 Markdown 或解释。
JSON 结构:
{"shouldEndChat":false,"terminationReason":null,"suggestions":["温和共情台词","冷静追问台词","施压质疑台词"],"functionSuggestions":[{"functionId":"...","actionText":"玩家动作文本"}]}
- suggestions 是玩家下一轮可直接说出口的中文短句,每条 20 字以内;三条必须按顺序导向不同氛围和好感结果。
- suggestions 第 1 条温和共情,通常让气氛缓和、好感上升;第 2 条冷静追问或试探,通常保持中性但推进情报;第 3 条施压、质疑或立场冲突,通常让气氛变紧、好感下降或付出代价。
- functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。
- functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId不写规则说明。
- 非敌对聊天 shouldEndChat 必须为 false。
- 敌对聊天可以随时 shouldEndChat=true且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#;
#[derive(Debug)]
pub(crate) struct NpcChatTurnPromptInput<'a> {
pub world_type: &'a str,
pub character: &'a Value,
pub encounter: &'a Value,
pub monsters: &'a [Value],
pub history: &'a [Value],
pub context: &'a Value,
pub conversation_history: &'a [Value],
pub dialogue: &'a [Value],
pub combat_context: Option<&'a Value>,
pub player_message: &'a str,
pub npc_state: &'a Value,
pub npc_initiates_conversation: bool,
pub chat_directive: Option<&'a Value>,
}
pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
let context = as_record(payload.context);
let npc_state = as_record(payload.npc_state);
let chat_directive = payload.chat_directive.and_then(as_record);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let opening_camp_background =
context.and_then(|record| read_string(record.get("openingCampBackground")));
let opening_camp_dialogue =
context.and_then(|record| read_string(record.get("openingCampDialogue")));
let allowed_topics = context
.and_then(|record| record.get("encounterAllowedTopics"))
.map(read_string_array)
.unwrap_or_default();
let blocked_topics = context
.and_then(|record| record.get("encounterBlockedTopics"))
.map(read_string_array)
.unwrap_or_default();
let is_first_meaningful_contact = context
.and_then(|record| read_bool(record.get("isFirstMeaningfulContact")))
.unwrap_or(false);
let affinity = npc_state
.and_then(|record| read_number(record.get("affinity")))
.unwrap_or(0.0);
let chatted_count = npc_state
.and_then(|record| read_number(record.get("chattedCount")))
.unwrap_or(0.0);
let limit_reason = chat_directive.and_then(|record| read_string(record.get("limitReason")));
let turn_limit = chat_directive
.and_then(|record| read_number(record.get("turnLimit")))
.unwrap_or(0.0)
.max(0.0);
let remaining_turns = chat_directive
.and_then(|record| read_number(record.get("remainingTurns")))
.unwrap_or(0.0)
.max(0.0);
let closing_mode = chat_directive.and_then(|record| read_string(record.get("closingMode")));
let is_limited_negative_affinity_chat =
limit_reason.as_deref() == Some("negative_affinity") && turn_limit > 0.0;
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let is_foreshadow_close_turn = closing_mode.as_deref() == Some("foreshadow_close")
|| chat_directive
.and_then(|record| read_bool(record.get("forceExitAfterTurn")))
.unwrap_or(false);
let has_npc_reply_in_history = conversation_history.iter().any(|item| {
as_record(item)
.and_then(|turn| read_string(turn.get("speaker")))
.is_some_and(|speaker| speaker == "npc")
});
let is_first_npc_spoken_turn =
is_first_meaningful_contact && !has_npc_reply_in_history && chatted_count <= 0.0;
let first_contact_relation_stance = describe_first_contact_relation_stance(
context.and_then(|record| record.get("firstContactRelationStance")),
);
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
opening_camp_background.map(|text| format!("营地开场背景:{text}")),
opening_camp_dialogue.map(|text| format!("刚刚发生的第一段对话:{text}")),
Some(format!("当前关系值:{}", format_prompt_number(affinity))),
Some(format!("已聊天轮次:{}", format_prompt_number(chatted_count))),
if is_first_npc_spoken_turn {
Some(format!(
"当前接触阶段:第一次真正接触({first_contact_relation_stance})。这是这次聊天里 {} 第一次真正对玩家开口。",
encounter.npc_name
))
} else {
None
},
if is_first_npc_spoken_turn {
Some("第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。".to_string())
} else {
None
},
if is_first_npc_spoken_turn {
Some("不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some(format!(
"当前要求:这是 {} 主动开口的第一句,不要假装玩家已经先说过话。",
encounter.npc_name
))
} else {
None
},
if allowed_topics.is_empty() {
None
} else {
Some(format!("当前更适合先谈:{}", allowed_topics.join("")))
},
if blocked_topics.is_empty() {
None
} else {
Some(format!("当前避免直接说破:{}", blocked_topics.join("")))
},
if is_limited_negative_affinity_chat {
Some(format!(
"当前相遇属于负好感主角色有限聊天,本次总上限 {} 轮。",
format_prompt_number(turn_limit)
))
} else {
None
},
if is_hostile_model_chat {
Some("当前是敌对或负好感聊天。对方不受固定回合限制,但随时可能不耐烦、结束谈话并把局势推向战斗或驱逐。".to_string())
} else {
None
},
if is_hostile_model_chat {
Some("敌对角色更偏好短促、戒备、带威胁的回应;如果玩家逼问、挑衅、退场或话题触到底线,回复应自然收束到对峙前一刻。".to_string())
} else {
None
},
if is_player_exit_turn {
Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string())
} else {
None
},
if is_limited_negative_affinity_chat {
Some(format!(
"在你回复完这一轮之后,还剩 {} 轮可以继续聊。",
format_prompt_number(remaining_turns)
))
} else {
None
},
if is_limited_negative_affinity_chat && !is_foreshadow_close_turn {
Some("语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。".to_string())
} else {
None
},
if is_foreshadow_close_turn {
Some("回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。".to_string())
} else {
None
},
if payload.npc_initiates_conversation {
Some("玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message.trim()))
},
if payload.npc_initiates_conversation {
Some(format!(
"现在请只写 {} 主动开口时会说的话。",
encounter.npc_name
))
} else {
Some(format!(
"现在请只写 {} 这一轮会回复玩家的话。",
encounter.npc_name
))
},
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
pub(crate) fn build_npc_chat_turn_suggestion_prompt(
payload: &NpcChatTurnPromptInput<'_>,
npc_reply: &str,
) -> String {
let encounter = describe_encounter(payload.encounter);
let conversation_history = if !payload.conversation_history.is_empty() {
payload.conversation_history
} else {
payload.dialogue
};
let combat_context_block = payload.combat_context.and_then(describe_npc_combat_context);
let chat_directive = payload.chat_directive.and_then(as_record);
let is_hostile_model_chat = chat_directive
.and_then(|record| read_string(record.get("terminationMode")))
.as_deref()
== Some("hostile_model")
|| chat_directive
.and_then(|record| read_bool(record.get("isHostileChat")))
.unwrap_or(false);
let is_player_exit_turn = chat_directive
.and_then(|record| read_string(record.get("terminationReason")))
.as_deref()
== Some("player_exit");
let function_options_block = chat_directive
.and_then(|record| record.get("functionOptions"))
.map(describe_function_options)
.filter(|text| !text.trim().is_empty());
[
Some(build_npc_dialogue_prompt_base(payload)),
Some(describe_npc_conversation_history(
conversation_history,
encounter.npc_name.as_str(),
)),
combat_context_block,
function_options_block,
if payload.npc_initiates_conversation {
Some("玩家尚未先开口,这一轮是 NPC 主动发起聊天。".to_string())
} else {
Some(format!("玩家刚刚说:{}", payload.player_message))
},
Some(format!("NPC 刚刚回复:{npc_reply}")),
if is_hostile_model_chat {
Some("这是敌对或负好感聊天。你需要判断这轮是否应该结束聊天;敌对角色更偏好随时终止并转入对峙。".to_string())
} else {
Some("这是非敌对聊天shouldEndChat 必须为 false。".to_string())
},
if is_player_exit_turn {
Some("玩家已经选择结束聊天shouldEndChat 必须为 trueterminationReason 必须为 player_exit。".to_string())
} else {
None
},
Some("suggestions 必须按顺序生成三种明显不同的玩家台词:温和共情、冷静追问或试探、施压质疑;不要给出同一种态度的近义句。".to_string()),
Some("functionSuggestions 从 functionOptions 中挑可触发动作并改写 actionText。".to_string()),
Some("只输出 JSON{\"shouldEndChat\":false,\"terminationReason\":null,\"suggestions\":[\"...\"],\"functionSuggestions\":[{\"functionId\":\"...\",\"actionText\":\"...\"}]}".to_string()),
]
.into_iter()
.flatten()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
fn describe_function_options(value: &Value) -> String {
let lines = value
.as_array()
.map(|items| {
items
.iter()
.take(8)
.filter_map(|item| {
let record = as_record(item)?;
let function_id = read_string(record.get("functionId"))?;
let action_text = read_string(record.get("actionText"))?;
let detail_text = read_string(record.get("detailText"));
let action = read_string(record.get("action"));
Some(format!(
"- functionId: {function_id}; actionText: {action_text}; action: {}; detail: {}",
action.unwrap_or_else(|| "unknown".to_string()),
detail_text.unwrap_or_else(|| "".to_string()),
))
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if lines.is_empty() {
return String::new();
}
let mut result = vec!["当前聊天中可改写为动作候选的 functionOptions".to_string()];
result.extend(lines);
result.join("\n")
}
fn build_npc_dialogue_prompt_base(payload: &NpcChatTurnPromptInput<'_>) -> String {
let encounter = describe_encounter(payload.encounter);
[
format!("世界:{}", describe_world(payload.world_type)),
describe_scene_context(payload.context),
describe_character("玩家 / ", payload.character),
encounter.block,
describe_monsters(payload.monsters),
describe_story_history(payload.history),
]
.into_iter()
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
struct EncounterDescription {
npc_name: String,
block: String,
}
fn describe_encounter(encounter: &Value) -> EncounterDescription {
let record = as_record(encounter);
let npc_name = record
.and_then(|item| read_string(item.get("npcName")))
.unwrap_or_else(|| "眼前角色".to_string());
let context_text = record
.and_then(|item| read_string(item.get("context")))
.or_else(|| record.and_then(|item| read_string(item.get("npcDescription"))))
.unwrap_or_else(|| "你们正在当前遭遇里继续对话。".to_string());
EncounterDescription {
npc_name: npc_name.clone(),
block: format!("当前对象:{npc_name}\n对象背景:{context_text}"),
}
}
fn describe_first_contact_relation_stance(value: Option<&Value>) -> String {
match value.and_then(|item| item.as_str()).map(str::trim) {
Some("guarded") => "戒备试探".to_string(),
Some("neutral") => "正常交流但仍不熟".to_string(),
Some("cooperative") => "已有善意,先确认合作节奏".to_string(),
Some("bonded") => "明显信任,但仍是第一次正式对上人".to_string(),
_ => "第一次真正接触".to_string(),
}
}
fn describe_world(world_type: &str) -> String {
match world_type {
"WUXIA" => "边城模板".to_string(),
"XIANXIA" => "灵潮模板".to_string(),
"CUSTOM" => "自定义世界".to_string(),
value if !value.trim().is_empty() => value.to_string(),
_ => "未知世界".to_string(),
}
}
fn describe_stats(label: &str, record: Option<&serde_json::Map<String, Value>>) -> String {
let hp = record
.and_then(|item| read_number(item.get("hp")))
.unwrap_or(0.0);
let max_hp = record
.and_then(|item| read_number(item.get("maxHp")))
.unwrap_or(hp)
.max(1.0);
let mana = record
.and_then(|item| read_number(item.get("mana")))
.unwrap_or(0.0);
let max_mana = record
.and_then(|item| read_number(item.get("maxMana")))
.unwrap_or(mana)
.max(1.0);
format!(
"{label}生命 {}/{},灵力 {}/{}",
format_prompt_number(hp),
format_prompt_number(max_hp),
format_prompt_number(mana),
format_prompt_number(max_mana)
)
}
fn describe_character(label: &str, value: &Value) -> String {
let record = as_record(value);
let name = record
.and_then(|item| read_string(item.get("name")))
.unwrap_or_else(|| "未知角色".to_string());
let title = record
.and_then(|item| read_string(item.get("title")))
.unwrap_or_else(|| "未知称号".to_string());
let description = record
.and_then(|item| read_string(item.get("description")))
.unwrap_or_else(|| "暂无额外描述".to_string());
let personality = record
.and_then(|item| read_string(item.get("personality")))
.unwrap_or_else(|| "性格信息未显式提供".to_string());
[
format!("{label}姓名:{name}"),
format!("{label}称号:{title}"),
format!("{label}描述:{description}"),
format!("{label}性格:{personality}"),
]
.join("\n")
}
fn describe_story_history(history: &[Value]) -> String {
if history.is_empty() {
return "近期剧情:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(4)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| as_record(item).and_then(|record| read_string(record.get("text"))))
.collect::<Vec<_>>();
if lines.is_empty() {
"近期剧情:暂无。".to_string()
} else {
let mut result = vec!["近期剧情:".to_string()];
result.extend(lines.into_iter().map(|line| format!("- {line}")));
result.join("\n")
}
}
fn describe_npc_conversation_history(history: &[Value], npc_name: &str) -> String {
if history.is_empty() {
return "当前聊天记录:暂无。".to_string();
}
let lines = history
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.filter_map(|item| {
let record = as_record(item)?;
let speaker = read_string(record.get("speaker"));
let speaker_name = read_string(record.get("speakerName"));
let text = read_string(record.get("text"))?;
match speaker.as_deref() {
Some("player") => Some(format!("- 玩家:{text}")),
Some("npc") => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| npc_name.to_string())
)),
Some("system") => Some(format!("- 系统提示:{text}")),
_ => Some(format!(
"- {}{text}",
speaker_name.unwrap_or_else(|| "同伴".to_string())
)),
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前聊天记录:暂无。".to_string()
} else {
let mut result = vec!["当前聊天记录:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn describe_npc_combat_context(combat_context: &Value) -> Option<String> {
let record = as_record(combat_context)?;
let summary = read_string(record.get("summary"));
let battle_outcome = read_string(record.get("battleOutcome"));
let log_lines = record
.get("logLines")
.map(read_string_array)
.unwrap_or_default()
.into_iter()
.take(6)
.collect::<Vec<_>>();
if summary.is_none() && log_lines.is_empty() {
return None;
}
let outcome_text = match battle_outcome.as_deref() {
Some("spar_complete") => Some("切磋刚刚结束。".to_string()),
Some("victory") => Some("战斗刚刚分出胜负。".to_string()),
_ => None,
};
let mut lines = vec!["刚刚结束的交锋:".to_string()];
if let Some(text) = outcome_text {
lines.push(text);
}
if let Some(text) = summary {
lines.push(format!("- 结果摘要:{text}"));
}
if !log_lines.is_empty() {
lines.push("- 战斗日志:".to_string());
lines.extend(log_lines.into_iter().map(|line| format!(" - {line}")));
}
Some(lines.join("\n"))
}
fn describe_scene_context(context: &Value) -> String {
let record = as_record(context);
let scene_name = record
.and_then(|item| read_string(item.get("sceneName")))
.unwrap_or_else(|| "当前区域".to_string());
let scene_description = record
.and_then(|item| read_string(item.get("sceneDescription")))
.unwrap_or_else(|| "周围气氛仍未完全安定。".to_string());
let in_battle = if record
.and_then(|item| read_bool(item.get("inBattle")))
.unwrap_or(false)
{
"战斗中"
} else {
"非战斗"
};
let custom_world_profile = record
.and_then(|item| item.get("customWorldProfile"))
.and_then(as_record);
let custom_world_name = custom_world_profile.and_then(|item| read_string(item.get("name")));
let custom_world_summary =
custom_world_profile.and_then(|item| read_string(item.get("summary")));
[
Some(format!(
"世界补充:{}",
custom_world_name.unwrap_or_else(|| "".to_string())
)),
custom_world_summary.map(|text| format!("世界摘要:{text}")),
Some(format!("场景:{scene_name}")),
Some(format!("场景描述:{scene_description}")),
Some(format!("当前状态:{in_battle}")),
Some(describe_stats("玩家", record)),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n")
}
fn describe_monsters(monsters: &[Value]) -> String {
if monsters.is_empty() {
return "当前敌对目标:无。".to_string();
}
let lines = monsters
.iter()
.take(4)
.filter_map(|item| {
let record = as_record(item)?;
let name = read_string(record.get("name"))
.or_else(|| read_string(record.get("npcName")))
.or_else(|| read_string(record.get("id")))?;
let hp = read_number(record.get("hp")).unwrap_or(0.0);
let max_hp = read_number(record.get("maxHp")).unwrap_or(hp).max(1.0);
Some(format!(
"- {name}(生命 {}/{})",
format_prompt_number(hp),
format_prompt_number(max_hp)
))
})
.collect::<Vec<_>>();
if lines.is_empty() {
"当前敌对目标:无。".to_string()
} else {
let mut result = vec!["当前敌对目标:".to_string()];
result.extend(lines);
result.join("\n")
}
}
fn read_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn read_number(value: Option<&Value>) -> Option<f64> {
value
.and_then(Value::as_f64)
.filter(|number| number.is_finite())
}
fn read_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn read_string_array(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| read_string(Some(item)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn as_record(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
fn format_prompt_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
value.to_string()
}
}

View File

@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
@@ -70,8 +71,8 @@ pub async fn put_runtime_snapshot(
let updated_at_micros = offset_datetime_to_unix_micros(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let record = state
.put_runtime_snapshot_record(
let record = if is_non_persistent_runtime_snapshot(&payload.game_state) {
build_transient_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
@@ -79,10 +80,21 @@ pub async fn put_runtime_snapshot(
payload.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?;
} else {
state
.put_runtime_snapshot_record(
user_id,
saved_at_micros,
payload.bottom_tab,
payload.game_state,
payload.current_story,
updated_at_micros,
)
.await
.map_err(|error| {
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
})?
};
Ok(json_success_body(
Some(&request_context),
@@ -184,6 +196,52 @@ fn build_saved_game_snapshot_response(
}
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> module_runtime::RuntimeSnapshotRecord {
// 中文注释:预览/测试入口可得到本次响应,但不能覆盖用户正式当前快照。
module_runtime::RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state_json: game_state.to_string(),
current_story_json: current_story.as_ref().map(Value::to_string),
game_state,
current_story,
created_at_micros: updated_at_micros,
updated_at_micros,
}
}
fn is_non_persistent_runtime_snapshot(game_state: &Value) -> bool {
let Some(game_state) = game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn build_profile_save_archive_summary_response(
record: &module_runtime::RuntimeProfileSaveArchiveRecord,
) -> ProfileSaveArchiveSummaryResponse {

View File

@@ -8,7 +8,7 @@ use module_npc::{
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
build_relation_state as build_module_npc_relation_state,
};
use module_runtime::RuntimeSnapshotRecord;
use module_runtime::{RuntimeSnapshotRecord, SAVE_SNAPSHOT_VERSION, format_utc_micros};
use module_runtime_story_compat::{
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
PendingQuestOfferContext, RuntimeStoryActionResponseParts, StoryResolution,
@@ -376,15 +376,28 @@ async fn persist_runtime_story_snapshot(
)
})?
.unwrap_or(now);
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
let updated_at_micros = offset_datetime_to_unix_micros(now);
if is_non_persistent_runtime_story_snapshot(&snapshot) {
return Ok(build_transient_runtime_snapshot_record(
user_id,
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
snapshot.current_story,
updated_at_micros,
));
}
state
.put_runtime_snapshot_record(
user_id,
offset_datetime_to_unix_micros(saved_at),
saved_at_micros,
snapshot.bottom_tab,
snapshot.game_state,
snapshot.current_story,
offset_datetime_to_unix_micros(now),
updated_at_micros,
)
.await
.map_err(|error| {
@@ -392,6 +405,52 @@ async fn persist_runtime_story_snapshot(
})
}
fn build_transient_runtime_snapshot_record(
user_id: String,
saved_at_micros: i64,
bottom_tab: String,
game_state: Value,
current_story: Option<Value>,
updated_at_micros: i64,
) -> RuntimeSnapshotRecord {
// 中文注释:预览/测试只需要本次响应里的 hydrated snapshot不能写入正式存档表。
RuntimeSnapshotRecord {
user_id,
version: SAVE_SNAPSHOT_VERSION,
saved_at: format_utc_micros(saved_at_micros),
saved_at_micros,
bottom_tab,
game_state_json: game_state.to_string(),
current_story_json: current_story.as_ref().map(Value::to_string),
game_state,
current_story,
created_at_micros: updated_at_micros,
updated_at_micros,
}
}
fn is_non_persistent_runtime_story_snapshot(snapshot: &RuntimeStorySnapshotPayload) -> bool {
let Some(game_state) = snapshot.game_state.as_object() else {
return false;
};
if game_state
.get("runtimePersistenceDisabled")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
matches!(
game_state
.get("runtimeMode")
.and_then(Value::as_str)
.map(str::trim),
Some("preview") | Some("test")
)
}
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
return Err("snapshot.bottomTab 不能为空".to_string());

View File

@@ -191,6 +191,133 @@ async fn runtime_story_routes_resolve_through_rust_route_boundary() {
);
}
#[tokio::test]
async fn runtime_story_preview_snapshot_returns_transient_response_without_overwriting_save() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let formal_response = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 1,
"worldType": "WUXIA",
"playerCharacter": { "id": "hero" },
"currentScene": "Story",
"runtimeStats": { "playTimeMs": 0 },
"storyHistory": []
},
"currentStory": {
"text": "正式存档里的故事。",
"options": []
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(formal_response.status(), StatusCode::OK);
let preview_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/runtime/story/actions/resolve")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-genarrative-response-envelope", "v1")
.body(Body::from(
json!({
"sessionId": "runtime-main",
"clientVersion": 3,
"action": {
"type": "story_choice",
"functionId": "idle_rest_focus"
},
"snapshot": {
"bottomTab": "adventure",
"gameState": {
"runtimeSessionId": "runtime-main",
"runtimeActionVersion": 3,
"runtimeMode": "preview",
"runtimePersistenceDisabled": true,
"playerHp": 10,
"playerMaxHp": 30,
"playerMana": 2,
"playerMaxMana": 12,
"storyHistory": []
},
"currentStory": {
"text": "幕预览里的临时故事。",
"options": []
}
}
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(preview_response.status(), StatusCode::OK);
let preview_payload: Value = serde_json::from_slice(
&preview_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
preview_payload["data"]["snapshot"]["gameState"]["runtimeMode"],
json!("preview")
);
let saved_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/runtime/save/snapshot")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-response-envelope", "v1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(saved_response.status(), StatusCode::OK);
let saved_payload: Value = serde_json::from_slice(
&saved_response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes(),
)
.expect("response should be json");
assert_eq!(
saved_payload["data"]["currentStory"]["text"],
json!("正式存档里的故事。")
);
assert!(saved_payload["data"]["gameState"]["runtimeMode"].is_null());
}
#[tokio::test]
async fn runtime_story_action_resolve_rejects_client_version_conflict() {
let state = seed_authenticated_state().await;