use module_big_fish::{BigFishAnchorPack, BigFishCreationStage}; use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; use spacetime_client::{ BigFishAgentMessageRecord, BigFishMessageFinalizeRecordInput, BigFishSessionRecord, }; use crate::creation_agent_anchor_templates::{ get_creation_agent_anchor_template, render_anchor_question_block, }; #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnRequest<'a> { pub llm_client: Option<&'a LlmClient>, pub session: &'a BigFishSessionRecord, } #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnResult { pub assistant_reply_text: String, pub stage: String, pub progress_percent: u32, pub anchor_pack_json: String, pub error_message: Option, } #[derive(Clone, Debug)] pub(crate) struct BigFishAgentTurnError { message: String, } impl BigFishAgentTurnError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for BigFishAgentTurnError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(&self.message) } } impl std::error::Error for BigFishAgentTurnError {} #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct BigFishAgentModelOutput { reply_text: String, progress_percent: u32, next_anchor_pack: BigFishAnchorPack, } const BIG_FISH_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创“大鱼吃小鱼”竖屏玩法的中文创意策划。 你必须把用户灵感收束成可以编译为可玩草稿的玩法、生态视觉、成长阶梯和风险节奏。 你必须同时输出: 1. 一段直接发给用户的中文回复 replyText 2. 当前进度 progressPercent 3. 下一轮完整可用的 nextAnchorPack 硬约束: 1. 只能输出 JSON,不能输出代码块或解释 2. nextAnchorPack 必须是完整对象,不能只输出 patch 3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 4. replyText 一次最多推进一个最关键问题 5. 必须对齐 RPG 共创的体验:先理解玩家幻想,再收束成能进入运行时的可玩效果 6. progressPercent 范围只能是 0 到 100 7. status 只能使用 missing / inferred / confirmed / locked "#; const BIG_FISH_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: { "replyText": "", "progressPercent": 0, "nextAnchorPack": { "gameplayPromise": { "key": "gameplayPromise", "label": "玩法承诺", "value": "", "status": "missing" }, "ecologyVisualTheme": { "key": "ecologyVisualTheme", "label": "生态视觉主题", "value": "", "status": "missing" }, "growthLadder": { "key": "growthLadder", "label": "成长阶梯", "value": "", "status": "missing" }, "riskTempo": { "key": "riskTempo", "label": "风险节奏", "value": "", "status": "missing" } } }"#; pub(crate) async fn run_big_fish_agent_turn( request: BigFishAgentTurnRequest<'_>, mut on_reply_update: F, ) -> Result where F: FnMut(&str), { let llm_client = request .llm_client .ok_or_else(|| BigFishAgentTurnError::new("当前模型不可用,请稍后重试。"))?; let prompt = build_big_fish_agent_prompt(request.session); let mut latest_reply_text = String::new(); let response = llm_client .stream_text( LlmTextRequest::new(vec![ LlmMessage::system(format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}")), LlmMessage::user("请按约定输出这一轮的 JSON。"), ]), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) && reply_progress != latest_reply_text { latest_reply_text = reply_progress.clone(); on_reply_update(reply_progress.as_str()); } }, ) .await .map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天生成失败,请稍后重试。"))?; let parsed = parse_json_response_text(response.content.as_str()) .map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果解析失败,请稍后重试。"))?; let output = parse_big_fish_model_output(&parsed)?; if output.reply_text != latest_reply_text { on_reply_update(output.reply_text.as_str()); } Ok(BigFishAgentTurnResult { assistant_reply_text: output.reply_text, stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(), progress_percent: output.progress_percent.min(100), anchor_pack_json: serde_json::to_string(&output.next_anchor_pack) .unwrap_or_else(|_| "{}".to_string()), error_message: None, }) } pub(crate) fn build_finalize_record_input( session_id: String, owner_user_id: String, assistant_message_id: String, result: BigFishAgentTurnResult, updated_at_micros: i64, ) -> BigFishMessageFinalizeRecordInput { BigFishMessageFinalizeRecordInput { session_id, owner_user_id, assistant_message_id: Some(assistant_message_id), assistant_reply_text: Some(result.assistant_reply_text), stage: result.stage, progress_percent: result.progress_percent, anchor_pack_json: result.anchor_pack_json, error_message: result.error_message, updated_at_micros, } } pub(crate) fn build_failed_finalize_record_input( session_id: String, owner_user_id: String, session: &BigFishSessionRecord, error_message: String, updated_at_micros: i64, ) -> BigFishMessageFinalizeRecordInput { BigFishMessageFinalizeRecordInput { session_id, owner_user_id, assistant_message_id: None, assistant_reply_text: None, stage: session.stage.clone(), progress_percent: session.progress_percent, anchor_pack_json: serialize_record_anchor_pack(&session.anchor_pack), error_message: Some(error_message), updated_at_micros, } } fn build_big_fish_agent_prompt(session: &BigFishSessionRecord) -> String { let anchor_question_block = get_creation_agent_anchor_template("big_fish") .map(render_anchor_question_block) .unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string()); format!( "{anchor_question_block}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", anchor_question_block = anchor_question_block, turn = session.current_turn.saturating_add(1), progress = session.progress_percent, anchor_pack = serialize_record_anchor_pack(&session.anchor_pack), chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) .unwrap_or_else(|_| "[]".to_string()), contract = BIG_FISH_AGENT_OUTPUT_CONTRACT, ) } fn build_chat_history(messages: &[BigFishAgentMessageRecord]) -> Vec { messages .iter() .map(|message| { json!({ "role": message.role, "kind": message.kind, "content": message.text, }) }) .collect() } fn parse_big_fish_model_output( parsed: &JsonValue, ) -> Result { serde_json::from_value::(parsed.clone()) .map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼模型结果缺少必要内容,请稍后重试。")) } fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String { serde_json::to_string_pretty(&json!({ "gameplayPromise": { "key": anchor_pack.gameplay_promise.key, "label": anchor_pack.gameplay_promise.label, "value": anchor_pack.gameplay_promise.value, "status": anchor_pack.gameplay_promise.status, }, "ecologyVisualTheme": { "key": anchor_pack.ecology_visual_theme.key, "label": anchor_pack.ecology_visual_theme.label, "value": anchor_pack.ecology_visual_theme.value, "status": anchor_pack.ecology_visual_theme.status, }, "growthLadder": { "key": anchor_pack.growth_ladder.key, "label": anchor_pack.growth_ladder.label, "value": anchor_pack.growth_ladder.value, "status": anchor_pack.growth_ladder.status, }, "riskTempo": { "key": anchor_pack.risk_tempo.key, "label": anchor_pack.risk_tempo.label, "value": anchor_pack.risk_tempo.value, "status": anchor_pack.risk_tempo.status, }, })) .unwrap_or_else(|_| "{}".to_string()) } fn parse_json_response_text(text: &str) -> Result { if let Ok(value) = serde_json::from_str::(text) { return Ok(value); } let Some(start) = text.find('{') else { return serde_json::from_str(text); }; let Some(end) = text.rfind('}') else { return serde_json::from_str(text); }; serde_json::from_str(&text[start..=end]) } fn extract_reply_text_from_partial_json(text: &str) -> Option { let marker = "\"replyText\""; let marker_index = text.find(marker)?; let after_marker = &text[marker_index + marker.len()..]; let colon_index = after_marker.find(':')?; let after_colon = after_marker[colon_index + 1..].trim_start(); let content = after_colon.strip_prefix('"')?; let mut result = String::new(); let mut escaped = false; for character in content.chars() { if escaped { result.push(match character { 'n' => '\n', 'r' => '\r', 't' => '\t', '"' => '"', '\\' => '\\', other => other, }); escaped = false; continue; } if character == '\\' { escaped = true; continue; } if character == '"' { return Some(result); } result.push(character); } if result.is_empty() { None } else { Some(result) } }