init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,429 @@
use module_big_fish::{BigFishAnchorPack, BigFishAnchorStatus, BigFishCreationStage};
use platform_llm::LlmClient;
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,
};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a BigFishSessionRecord,
pub quick_fill_requested: bool,
}
#[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<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct BigFishAgentTurnError {
message: String,
}
impl BigFishAgentTurnError {
fn new(message: impl Into<String>) -> 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<F>(
request: BigFishAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_big_fish_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{BIG_FISH_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
"请按约定输出这一轮的 JSON。",
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "大鱼吃小鱼聊天生成失败,请稍后重试。",
parse_failed: "大鱼吃小鱼聊天结果解析失败,请稍后重试。",
},
on_reply_update,
BigFishAgentTurnError::new,
)
.await?;
let output = parse_big_fish_model_output(&turn_output.parsed)?;
Ok(BigFishAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: BigFishCreationStage::CollectingAnchors.as_str().to_string(),
progress_percent: if request.quick_fill_requested {
100
} else {
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,
quick_fill_requested: bool,
) -> String {
let anchor_question_block = get_creation_agent_anchor_template("big_fish")
.map(render_anchor_question_block)
.unwrap_or_else(|| "模板目标:收束成可玩的竖屏大鱼吃小鱼玩法草稿。".to_string());
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前玩法方向里的成长、生态、风险节奏等缺失关键词",
"不要要求用户再提供等级、鱼群、场景或节奏信息",
"输出完整 nextAnchorPack直接补齐 value 为空或 status 为 missing 的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
anchor_question_block = anchor_question_block,
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
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<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_big_fish_model_output(
parsed: &JsonValue,
) -> Result<BigFishAgentModelOutput, BigFishAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack_value = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?;
let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?;
Ok(BigFishAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn parse_big_fish_model_anchor_pack(
value: &JsonValue,
) -> Result<BigFishAnchorPack, BigFishAgentTurnError> {
Ok(BigFishAnchorPack {
// LLM 与 HTTP 契约使用 camelCaseSpacetimeDB 持久化结构保持 Rust snake_case边界处必须显式翻译。
gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?,
ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?,
growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?,
risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?,
})
}
fn parse_big_fish_model_anchor_item(
pack: &JsonValue,
field_name: &str,
) -> Result<module_big_fish::BigFishAnchorItem, BigFishAgentTurnError> {
let value = pack.get(field_name).ok_or_else(|| {
BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}"))
})?;
let key = value
.get("key")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or(field_name)
.to_string();
let label = value
.get("label")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.unwrap_or_else(|| default_big_fish_anchor_label(field_name))
.to_string();
let item_value = value
.get("value")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default()
.to_string();
let status = value
.get("status")
.and_then(JsonValue::as_str)
.map(parse_big_fish_anchor_status)
.unwrap_or(BigFishAnchorStatus::Missing);
Ok(module_big_fish::BigFishAnchorItem {
key,
label,
value: item_value,
status,
})
}
fn default_big_fish_anchor_label(field_name: &str) -> &'static str {
match field_name {
"gameplayPromise" => "玩法承诺",
"ecologyVisualTheme" => "生态视觉主题",
"growthLadder" => "成长阶梯",
"riskTempo" => "风险节奏",
_ => "大鱼锚点",
}
}
fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String {
serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack))
.unwrap_or_else(|_| "{}".to_string())
}
fn map_big_fish_record_anchor_pack(
record: &spacetime_client::BigFishAnchorPackRecord,
) -> BigFishAnchorPack {
BigFishAnchorPack {
gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise),
ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme),
growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder),
risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo),
}
}
fn map_big_fish_record_anchor_item(
record: &spacetime_client::BigFishAnchorItemRecord,
) -> module_big_fish::BigFishAnchorItem {
module_big_fish::BigFishAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_big_fish_anchor_status(record.status.as_str()),
}
}
fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus {
match value {
"confirmed" => BigFishAnchorStatus::Confirmed,
"locked" => BigFishAnchorStatus::Locked,
"inferred" => BigFishAnchorStatus::Inferred,
_ => BigFishAnchorStatus::Missing,
}
}
#[cfg(test)]
mod tests {
use super::build_big_fish_agent_prompt;
fn anchor_item(
key: &str,
label: &str,
value: &str,
status: &str,
) -> spacetime_client::BigFishAnchorItemRecord {
spacetime_client::BigFishAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: status.to_string(),
}
}
fn empty_session_record() -> spacetime_client::BigFishSessionRecord {
spacetime_client::BigFishSessionRecord {
session_id: "big-fish-session-test".to_string(),
current_turn: 2,
progress_percent: 60,
stage: "collecting_anchors".to_string(),
anchor_pack: spacetime_client::BigFishAnchorPackRecord {
gameplay_promise: anchor_item(
"gameplayPromise",
"玩法承诺",
"微光小鱼逆袭深海巨兽",
"confirmed",
),
ecology_visual_theme: anchor_item(
"ecologyVisualTheme",
"生态视觉主题",
"幽蓝珊瑚海沟",
"confirmed",
),
growth_ladder: anchor_item("growthLadder", "成长阶梯", "", "missing"),
risk_tempo: anchor_item("riskTempo", "风险节奏", "", "missing"),
},
draft: None,
asset_slots: Vec::new(),
asset_coverage: spacetime_client::BigFishAssetCoverageRecord {
level_main_image_ready_count: 0,
level_motion_ready_count: 0,
background_ready: false,
required_level_count: 8,
publish_ready: false,
blockers: Vec::new(),
},
messages: Vec::new(),
last_assistant_reply: None,
publish_ready: false,
updated_at: "2026-04-24T10:00:00.000Z".to_string(),
}
}
#[test]
fn quick_fill_prompt_forbids_follow_up_questions() {
let prompt = build_big_fish_agent_prompt(&empty_session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}