1
This commit is contained in:
@@ -37,6 +37,10 @@ use spacetime_client::{
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::big_fish_agent_turn::{
|
||||
BigFishAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_big_fish_agent_turn,
|
||||
};
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
@@ -157,11 +161,12 @@ pub async fn submit_big_fish_message(
|
||||
));
|
||||
}
|
||||
|
||||
let session = state
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let submitted_session = state
|
||||
.spacetime_client()
|
||||
.submit_big_fish_message(BigFishMessageSubmitRecordInput {
|
||||
session_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
user_message_id: client_message_id,
|
||||
user_message_text: message_text,
|
||||
assistant_message_id: build_prefixed_uuid_id("big-fish-message-"),
|
||||
@@ -171,6 +176,37 @@ pub async fn submit_big_fish_message(
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
let turn_result = run_big_fish_agent_turn(
|
||||
BigFishAgentTurnRequest {
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
build_prefixed_uuid_id("big-fish-message-"),
|
||||
turn_result,
|
||||
current_utc_micros(),
|
||||
),
|
||||
Err(error) => build_failed_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
&submitted_session,
|
||||
error.to_string(),
|
||||
current_utc_micros(),
|
||||
),
|
||||
};
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.finalize_big_fish_agent_message(finalize_input)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -198,14 +234,23 @@ pub async fn stream_big_fish_message(
|
||||
})?;
|
||||
ensure_non_empty(&request_context, &session_id, "sessionId")?;
|
||||
|
||||
let client_message_id = payload.client_message_id.trim().to_string();
|
||||
let message_text = payload.text.trim().to_string();
|
||||
if client_message_id.is_empty() || message_text.is_empty() {
|
||||
return Err(big_fish_bad_request(
|
||||
&request_context,
|
||||
"clientMessageId and text are required",
|
||||
));
|
||||
}
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let session = state
|
||||
let submitted_session = state
|
||||
.spacetime_client()
|
||||
.submit_big_fish_message(BigFishMessageSubmitRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
user_message_id: payload.client_message_id.trim().to_string(),
|
||||
user_message_text: payload.text.trim().to_string(),
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
user_message_id: client_message_id,
|
||||
user_message_text: message_text,
|
||||
assistant_message_id: build_prefixed_uuid_id("big-fish-message-"),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
@@ -213,18 +258,52 @@ pub async fn stream_big_fish_message(
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
let mut streamed_reply_text = String::new();
|
||||
let turn_result = run_big_fish_agent_turn(
|
||||
BigFishAgentTurnRequest {
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
},
|
||||
|text| {
|
||||
streamed_reply_text = text.to_string();
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let reply_text = match &turn_result {
|
||||
Ok(result) => result.assistant_reply_text.clone(),
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
build_prefixed_uuid_id("big-fish-message-"),
|
||||
turn_result,
|
||||
current_utc_micros(),
|
||||
),
|
||||
Err(error) => build_failed_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
&submitted_session,
|
||||
error.to_string(),
|
||||
current_utc_micros(),
|
||||
),
|
||||
};
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.finalize_big_fish_agent_message(finalize_input)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
big_fish_error_response(&request_context, map_big_fish_client_error(error))
|
||||
})?;
|
||||
|
||||
let session_response = map_big_fish_session_response(session);
|
||||
let reply_text = session_response
|
||||
.last_assistant_reply
|
||||
.clone()
|
||||
.unwrap_or_else(|| "锚点已更新。".to_string());
|
||||
let mut sse_body = String::new();
|
||||
append_sse_event(
|
||||
&request_context,
|
||||
&mut sse_body,
|
||||
"reply_delta",
|
||||
&json!({ "text": reply_text }),
|
||||
&json!({ "text": if streamed_reply_text.is_empty() { reply_text } else { streamed_reply_text } }),
|
||||
)?;
|
||||
append_sse_event(
|
||||
&request_context,
|
||||
|
||||
312
server-rs/crates/api-server/src/big_fish_agent_turn.rs
Normal file
312
server-rs/crates/api-server/src/big_fish_agent_turn.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
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<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<'_>,
|
||||
mut on_reply_update: F,
|
||||
) -> Result<BigFishAgentTurnResult, BigFishAgentTurnError>
|
||||
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<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> {
|
||||
serde_json::from_value::<BigFishAgentModelOutput>(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<JsonValue, serde_json::Error> {
|
||||
if let Ok(value) = serde_json::from_str::<JsonValue>(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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"templateId": "rpg_world",
|
||||
"displayName": "RPG 世界共创",
|
||||
"creationGoal": "收束成可直接进入 RPG 运行时的世界、角色、冲突、入口与隐藏线索。",
|
||||
"anchorQuestions": [
|
||||
{
|
||||
"key": "worldPromise",
|
||||
"label": "世界承诺",
|
||||
"question": "这个世界最想让玩家体验到什么独特承诺?",
|
||||
"requiredEffect": "明确世界钩子、差异化体验与玩家期待。"
|
||||
},
|
||||
{
|
||||
"key": "playerFantasy",
|
||||
"label": "玩家幻想",
|
||||
"question": "玩家进入后扮演谁、追求什么、害怕失去什么?",
|
||||
"requiredEffect": "明确身份、目标与核心情绪压力。"
|
||||
},
|
||||
{
|
||||
"key": "themeBoundary",
|
||||
"label": "题材边界",
|
||||
"question": "题材气质、视觉方向和禁忌边界分别是什么?",
|
||||
"requiredEffect": "约束语气、审美与不可越界内容。"
|
||||
},
|
||||
{
|
||||
"key": "playerEntryPoint",
|
||||
"label": "玩家入口",
|
||||
"question": "玩家第一幕以什么身份遇到什么问题?",
|
||||
"requiredEffect": "形成可开局的身份、问题和行动动机。"
|
||||
},
|
||||
{
|
||||
"key": "coreConflict",
|
||||
"label": "核心冲突",
|
||||
"question": "表层冲突、隐藏危机和首个触发冲突是什么?",
|
||||
"requiredEffect": "建立运行时持续推进的矛盾发动机。"
|
||||
},
|
||||
{
|
||||
"key": "keyRelationships",
|
||||
"label": "关键关系",
|
||||
"question": "哪些人物关系最能推动选择、秘密和代价?",
|
||||
"requiredEffect": "形成 NPC、势力或亲密关系的互动张力。"
|
||||
},
|
||||
{
|
||||
"key": "hiddenLine",
|
||||
"label": "隐藏线",
|
||||
"question": "世界真相如何被误导、铺垫并逐步揭示?",
|
||||
"requiredEffect": "为中后期探索与反转留下结构化线索。"
|
||||
},
|
||||
{
|
||||
"key": "iconicElements",
|
||||
"label": "标志元素",
|
||||
"question": "哪些标志物、机构、规则或意象让世界被记住?",
|
||||
"requiredEffect": "提供可复用的视觉、剧情和系统记忆点。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"templateId": "puzzle",
|
||||
"displayName": "拼图共创",
|
||||
"creationGoal": "收束成可以发布为拼图关卡的视觉方案。",
|
||||
"anchorQuestions": [
|
||||
{
|
||||
"key": "themePromise",
|
||||
"label": "题材承诺",
|
||||
"question": "这张拼图给玩家的题材和完成期待是什么?",
|
||||
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
|
||||
},
|
||||
{
|
||||
"key": "visualSubject",
|
||||
"label": "画面主体",
|
||||
"question": "画面中最需要被玩家一眼看懂的主体是什么?",
|
||||
"requiredEffect": "明确主体、层级和可被切片识别的形状。"
|
||||
},
|
||||
{
|
||||
"key": "visualMood",
|
||||
"label": "视觉气质",
|
||||
"question": "整体色彩、光线、情绪和美术风格是什么?",
|
||||
"requiredEffect": "收束画面风格,避免结果图风格漂移。"
|
||||
},
|
||||
{
|
||||
"key": "compositionHooks",
|
||||
"label": "拼图记忆点",
|
||||
"question": "哪些构图、纹理或局部细节会成为玩家拼接线索?",
|
||||
"requiredEffect": "提供适合拼图玩法的边缘、块面和局部记忆点。"
|
||||
},
|
||||
{
|
||||
"key": "tagsAndForbidden",
|
||||
"label": "标签与禁忌",
|
||||
"question": "需要保留哪些关键词,又必须避开什么内容?",
|
||||
"requiredEffect": "锁定生成标签和负向约束。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"templateId": "big_fish",
|
||||
"displayName": "大鱼吃小鱼共创",
|
||||
"creationGoal": "收束成可直接编译为竖屏大鱼吃小鱼玩法草稿的成长、生态、节奏方案。",
|
||||
"anchorQuestions": [
|
||||
{
|
||||
"key": "gameplayPromise",
|
||||
"label": "玩法承诺",
|
||||
"question": "这版大鱼吃小鱼最核心的吞噬成长爽点是什么?",
|
||||
"requiredEffect": "明确玩家为什么要持续吞噬、升级和冒险。"
|
||||
},
|
||||
{
|
||||
"key": "ecologyVisualTheme",
|
||||
"label": "生态视觉主题",
|
||||
"question": "鱼群、场景和敌我生态的视觉主题是什么?",
|
||||
"requiredEffect": "提供后续角色图、动作图和背景图的一致视觉方向。"
|
||||
},
|
||||
{
|
||||
"key": "growthLadder",
|
||||
"label": "成长阶梯",
|
||||
"question": "从弱小到终局巨兽的 6 到 12 级成长阶梯如何递进?",
|
||||
"requiredEffect": "保证等级轮廓、体型、能力幻想逐级增强。"
|
||||
},
|
||||
{
|
||||
"key": "riskTempo",
|
||||
"label": "风险节奏",
|
||||
"question": "玩家在每个阶段面对的威胁压力和爽快节奏如何变化?",
|
||||
"requiredEffect": "确定猎物、威胁、压迫感和爽感之间的节奏比例。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const ANCHOR_TEMPLATE_CONFIG: &str = include_str!("creation_agent_anchor_templates.json");
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreationAgentAnchorTemplateConfig {
|
||||
templates: Vec<CreationAgentAnchorTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreationAgentAnchorTemplate {
|
||||
pub template_id: String,
|
||||
pub display_name: String,
|
||||
pub creation_goal: String,
|
||||
pub anchor_questions: Vec<CreationAgentAnchorQuestion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreationAgentAnchorQuestion {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub question: String,
|
||||
pub required_effect: String,
|
||||
}
|
||||
|
||||
static ANCHOR_TEMPLATES: OnceLock<CreationAgentAnchorTemplateConfig> = OnceLock::new();
|
||||
|
||||
pub(crate) fn get_creation_agent_anchor_template(
|
||||
template_id: &str,
|
||||
) -> Option<&'static CreationAgentAnchorTemplate> {
|
||||
load_creation_agent_anchor_templates()
|
||||
.templates
|
||||
.iter()
|
||||
.find(|template| template.template_id == template_id)
|
||||
}
|
||||
|
||||
pub(crate) fn render_anchor_question_block(template: &CreationAgentAnchorTemplate) -> String {
|
||||
let mut lines = vec![
|
||||
format!("模板目标:{}", template.creation_goal),
|
||||
"".to_string(),
|
||||
];
|
||||
lines.push("本模板只通过以下锚点问题体现差异:".to_string());
|
||||
for (index, question) in template.anchor_questions.iter().enumerate() {
|
||||
lines.push(format!(
|
||||
"{}. {}({} / {}):{};达成效果:{}",
|
||||
index + 1,
|
||||
question.label,
|
||||
question.key,
|
||||
template.display_name,
|
||||
question.question,
|
||||
question.required_effect
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn load_creation_agent_anchor_templates() -> &'static CreationAgentAnchorTemplateConfig {
|
||||
ANCHOR_TEMPLATES.get_or_init(|| {
|
||||
serde_json::from_str(ANCHOR_TEMPLATE_CONFIG)
|
||||
.expect("creation_agent_anchor_templates.json 必须是合法配置")
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,7 @@ use std::convert::Infallible;
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_agent_entities::generate_custom_world_agent_entities,
|
||||
custom_world_agent_turn::{
|
||||
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
|
||||
build_finalize_record_input, run_custom_world_agent_turn,
|
||||
@@ -916,7 +917,10 @@ pub async fn execute_custom_world_agent_action(
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let submitted_at_micros = current_utc_micros();
|
||||
let payload_json = if action == "draft_foundation" {
|
||||
let payload_json = if matches!(
|
||||
action.as_str(),
|
||||
"draft_foundation" | "generate_characters" | "generate_landmarks"
|
||||
) {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
@@ -924,15 +928,6 @@ pub async fn execute_custom_world_agent_action(
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
if session.progress_percent < 100 {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": "draft_foundation requires progressPercent >= 100",
|
||||
})),
|
||||
));
|
||||
}
|
||||
let llm_client = state.llm_client().ok_or_else(|| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
@@ -942,19 +937,29 @@ pub async fn execute_custom_world_agent_action(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
custom_world_error_response(
|
||||
if action == "draft_foundation" {
|
||||
if session.progress_percent < 100 {
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": message,
|
||||
"message": "draft_foundation requires progressPercent >= 100",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
|
||||
.map_err(|error| {
|
||||
));
|
||||
}
|
||||
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
|
||||
.map_err(|error| {
|
||||
let (status, message) = match error {
|
||||
DraftFoundationPayloadError::SerializePayload(message) => {
|
||||
(StatusCode::BAD_REQUEST, message)
|
||||
@@ -975,6 +980,21 @@ pub async fn execute_custom_world_agent_action(
|
||||
})),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
let generation_result =
|
||||
generate_custom_world_agent_entities(llm_client, &session, &payload)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
generation_result.payload_json
|
||||
}
|
||||
} else {
|
||||
serde_json::to_string(&payload).map_err(|error| {
|
||||
custom_world_error_response(
|
||||
|
||||
641
server-rs/crates/api-server/src/custom_world_agent_entities.rs
Normal file
641
server-rs/crates/api-server/src/custom_world_agent_entities.rs
Normal file
@@ -0,0 +1,641 @@
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map as JsonMap, Value as JsonValue, json};
|
||||
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
|
||||
use spacetime_client::CustomWorldAgentSessionRecord;
|
||||
|
||||
const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str =
|
||||
"你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。";
|
||||
const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT: &str =
|
||||
"你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CustomWorldGeneratedEntitiesResult {
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CustomWorldGeneratedEntitiesPayloadError {
|
||||
SerializePayload(String),
|
||||
InvalidPayloadShape,
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_agent_entities(
|
||||
llm_client: &LlmClient,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
) -> Result<CustomWorldGeneratedEntitiesResult, String> {
|
||||
let action = payload.action.trim();
|
||||
let draft_profile = session
|
||||
.draft_profile
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("{action} requires an existing draft foundation"))?;
|
||||
let count = ensure_count(payload.count);
|
||||
let prompt_seed = payload
|
||||
.prompt_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("没有额外要求,围绕当前底稿自然扩展。");
|
||||
let anchor_summary = build_anchor_summary(
|
||||
draft_profile,
|
||||
payload.anchor_card_ids.as_deref().unwrap_or(&[]),
|
||||
);
|
||||
let creator_intent_summary = session
|
||||
.anchor_pack
|
||||
.get("creatorIntentSummary")
|
||||
.and_then(JsonValue::as_str)
|
||||
.or_else(|| {
|
||||
session
|
||||
.creator_intent
|
||||
.get("worldHook")
|
||||
.and_then(JsonValue::as_str)
|
||||
})
|
||||
.or_else(|| draft_profile.get("summary").and_then(JsonValue::as_str))
|
||||
.unwrap_or_default();
|
||||
let world_name = draft_profile
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or("未命名世界");
|
||||
let world_summary = draft_profile
|
||||
.get("summary")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (system_prompt, user_prompt, result_key) = match action {
|
||||
"generate_characters" => (
|
||||
CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
|
||||
build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
|
||||
world_name,
|
||||
world_summary,
|
||||
creator_intent_summary,
|
||||
anchor_summary: anchor_summary.as_str(),
|
||||
existing_names: existing_character_names(draft_profile),
|
||||
count,
|
||||
prompt_seed,
|
||||
}),
|
||||
"generatedCharacters",
|
||||
),
|
||||
"generate_landmarks" => (
|
||||
CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
|
||||
build_custom_world_agent_landmark_expansion_prompt(ExpansionPromptParams {
|
||||
world_name,
|
||||
world_summary,
|
||||
creator_intent_summary,
|
||||
anchor_summary: anchor_summary.as_str(),
|
||||
existing_names: existing_landmark_names(draft_profile),
|
||||
count,
|
||||
prompt_seed,
|
||||
}),
|
||||
"generatedLandmarks",
|
||||
),
|
||||
_ => return Err(format!("unsupported generated entity action: {action}")),
|
||||
};
|
||||
|
||||
let response = llm_client
|
||||
.request_text(LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]))
|
||||
.await
|
||||
.map_err(|error| format!("{action} LLM 请求失败:{error}"))?;
|
||||
let generated_entities = parse_json_array_response(response.content.as_str())
|
||||
.map_err(|error| format!("{action} JSON 解析失败:{error}"))?;
|
||||
let normalized_entities =
|
||||
normalize_generated_entities(action, generated_entities, draft_profile, count);
|
||||
let payload_json =
|
||||
build_generated_entities_action_payload_json(payload, result_key, normalized_entities)
|
||||
.map_err(|error| match error {
|
||||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(message) => message,
|
||||
CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape => {
|
||||
"action payload 必须是 object".to_string()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(CustomWorldGeneratedEntitiesResult { payload_json })
|
||||
}
|
||||
|
||||
pub fn build_generated_entities_action_payload_json(
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
result_key: &str,
|
||||
generated_entities: Vec<JsonValue>,
|
||||
) -> Result<String, CustomWorldGeneratedEntitiesPayloadError> {
|
||||
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
|
||||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
|
||||
"action payload JSON 序列化失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let payload_object = payload_value
|
||||
.as_object_mut()
|
||||
.ok_or(CustomWorldGeneratedEntitiesPayloadError::InvalidPayloadShape)?;
|
||||
if payload.action.trim() == "generate_characters" {
|
||||
payload_object.insert(
|
||||
"roleType".to_string(),
|
||||
JsonValue::String(resolve_role_type(payload.role_type.as_deref()).to_string()),
|
||||
);
|
||||
}
|
||||
payload_object.insert(result_key.to_string(), JsonValue::Array(generated_entities));
|
||||
serde_json::to_string(&payload_value).map_err(|error| {
|
||||
CustomWorldGeneratedEntitiesPayloadError::SerializePayload(format!(
|
||||
"action payload JSON 序列化失败:{error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
struct ExpansionPromptParams<'a> {
|
||||
world_name: &'a str,
|
||||
world_summary: &'a str,
|
||||
creator_intent_summary: &'a str,
|
||||
anchor_summary: &'a str,
|
||||
existing_names: Vec<String>,
|
||||
count: u32,
|
||||
prompt_seed: &'a str,
|
||||
}
|
||||
|
||||
fn build_custom_world_agent_character_expansion_prompt(
|
||||
params: ExpansionPromptParams<'_>,
|
||||
) -> String {
|
||||
[
|
||||
format!("当前世界:{}", params.world_name),
|
||||
format!("世界摘要:{}", params.world_summary),
|
||||
format!("创作意图摘要:{}", params.creator_intent_summary),
|
||||
format!("参考锚点:{}", params.anchor_summary),
|
||||
format!(
|
||||
"已有角色:{}",
|
||||
if params.existing_names.is_empty() {
|
||||
"暂无".to_string()
|
||||
} else {
|
||||
params.existing_names.join("、")
|
||||
}
|
||||
),
|
||||
format!("数量:{}", params.count),
|
||||
format!(
|
||||
"补充要求:{}",
|
||||
if params.prompt_seed.trim().is_empty() {
|
||||
"没有额外要求,围绕当前底稿自然扩展。"
|
||||
} else {
|
||||
params.prompt_seed
|
||||
}
|
||||
),
|
||||
"返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。".to_string(),
|
||||
"threadIds 必须优先引用现有线程 id。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_custom_world_agent_landmark_expansion_prompt(params: ExpansionPromptParams<'_>) -> String {
|
||||
[
|
||||
format!("当前世界:{}", params.world_name),
|
||||
format!("世界摘要:{}", params.world_summary),
|
||||
format!("创作意图摘要:{}", params.creator_intent_summary),
|
||||
format!("参考锚点:{}", params.anchor_summary),
|
||||
format!(
|
||||
"已有地点:{}",
|
||||
if params.existing_names.is_empty() {
|
||||
"暂无".to_string()
|
||||
} else {
|
||||
params.existing_names.join("、")
|
||||
}
|
||||
),
|
||||
format!("数量:{}", params.count),
|
||||
format!(
|
||||
"补充要求:{}",
|
||||
if params.prompt_seed.trim().is_empty() {
|
||||
"没有额外要求,围绕当前底稿自然扩展。"
|
||||
} else {
|
||||
params.prompt_seed
|
||||
}
|
||||
),
|
||||
"返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。".to_string(),
|
||||
"threadIds / characterIds 必须优先引用现有对象 id。".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn ensure_count(count: Option<u32>) -> u32 {
|
||||
count.unwrap_or(1).clamp(1, 3)
|
||||
}
|
||||
|
||||
fn resolve_role_type(role_type: Option<&str>) -> &'static str {
|
||||
match role_type.map(str::trim) {
|
||||
Some("playable") => "playable",
|
||||
_ => "story",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_array_response(text: &str) -> Result<Vec<JsonValue>, serde_json::Error> {
|
||||
let trimmed = text.trim();
|
||||
if let Some(start) = trimmed.find('[')
|
||||
&& let Some(end) = trimmed.rfind(']')
|
||||
&& end >= start
|
||||
{
|
||||
return serde_json::from_str::<Vec<JsonValue>>(&trimmed[start..=end]);
|
||||
}
|
||||
serde_json::from_str::<Vec<JsonValue>>(trimmed)
|
||||
}
|
||||
|
||||
fn normalize_generated_entities(
|
||||
action: &str,
|
||||
entities: Vec<JsonValue>,
|
||||
draft_profile: &JsonMap<String, JsonValue>,
|
||||
count: u32,
|
||||
) -> Vec<JsonValue> {
|
||||
let mut existing_names = if action == "generate_characters" {
|
||||
existing_character_names(draft_profile)
|
||||
} else {
|
||||
existing_landmark_names(draft_profile)
|
||||
};
|
||||
entities
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.as_object().cloned())
|
||||
.filter_map(|mut object| {
|
||||
let name = object
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
if existing_names.iter().any(|entry| entry == &name) {
|
||||
return None;
|
||||
}
|
||||
existing_names.push(name.clone());
|
||||
let prefix = if action == "generate_characters" {
|
||||
"character"
|
||||
} else {
|
||||
"landmark"
|
||||
};
|
||||
object.entry("id".to_string()).or_insert_with(|| {
|
||||
JsonValue::String(create_stable_id(
|
||||
prefix,
|
||||
name.as_str(),
|
||||
existing_names.len(),
|
||||
))
|
||||
});
|
||||
normalize_generated_entity_profile_fields(action, &mut object);
|
||||
Some(JsonValue::Object(object))
|
||||
})
|
||||
.take(count as usize)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_generated_entity_profile_fields(
|
||||
action: &str,
|
||||
object: &mut JsonMap<String, JsonValue>,
|
||||
) {
|
||||
if action == "generate_characters" {
|
||||
normalize_generated_character_profile_fields(object);
|
||||
} else {
|
||||
normalize_generated_landmark_profile_fields(object);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_generated_character_profile_fields(object: &mut JsonMap<String, JsonValue>) {
|
||||
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景角色".to_string());
|
||||
let role = read_object_text(object, "role").unwrap_or_else(|| "场景角色".to_string());
|
||||
let summary = read_first_object_text(object, &["description", "summary", "publicMask"])
|
||||
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的{role}。"));
|
||||
let hidden_hook = read_object_text(object, "hiddenHook").unwrap_or_else(|| summary.clone());
|
||||
|
||||
insert_text_if_missing(object, "title", role.as_str());
|
||||
insert_text_if_missing(object, "description", summary.as_str());
|
||||
insert_text_if_missing(object, "backstory", hidden_hook.as_str());
|
||||
insert_text_if_missing(object, "personality", "待在后续互动中揭示");
|
||||
insert_text_if_missing(object, "motivation", hidden_hook.as_str());
|
||||
insert_text_if_missing(object, "combatStyle", "围绕自身身份采取行动");
|
||||
object
|
||||
.entry("initialAffinity".to_string())
|
||||
.or_insert_with(|| JsonValue::Number(6.into()));
|
||||
|
||||
if !object
|
||||
.get("relationshipHooks")
|
||||
.is_some_and(JsonValue::is_array)
|
||||
{
|
||||
let mut hooks = Vec::new();
|
||||
if let Some(relation) = read_object_text(object, "relationToPlayer") {
|
||||
hooks.push(JsonValue::String(relation));
|
||||
}
|
||||
if hooks.is_empty() {
|
||||
hooks.push(JsonValue::String("等待玩家接触".to_string()));
|
||||
}
|
||||
object.insert("relationshipHooks".to_string(), JsonValue::Array(hooks));
|
||||
}
|
||||
|
||||
if !object.get("tags").is_some_and(JsonValue::is_array) {
|
||||
object.insert(
|
||||
"tags".to_string(),
|
||||
JsonValue::Array(vec![JsonValue::String(role)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_generated_landmark_profile_fields(object: &mut JsonMap<String, JsonValue>) {
|
||||
let name = read_object_text(object, "name").unwrap_or_else(|| "新场景".to_string());
|
||||
let description = read_first_object_text(object, &["description", "summary", "purpose"])
|
||||
.unwrap_or_else(|| format!("{name}是围绕当前世界新补出的关键场景。"));
|
||||
let mut description_parts = vec![description];
|
||||
for key in ["mood", "secret"] {
|
||||
if let Some(text) = read_object_text(object, key)
|
||||
&& !description_parts.iter().any(|entry| entry == &text)
|
||||
{
|
||||
description_parts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
insert_text_if_missing(object, "description", description_parts.join(" ").as_str());
|
||||
insert_text_if_missing(object, "dangerLevel", "中");
|
||||
object
|
||||
.entry("connections".to_string())
|
||||
.or_insert_with(|| JsonValue::Array(Vec::new()));
|
||||
|
||||
if !object.get("sceneNpcIds").is_some_and(JsonValue::is_array) {
|
||||
let npc_ids = object
|
||||
.get("characterIds")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| JsonValue::String(value.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
object.insert("sceneNpcIds".to_string(), JsonValue::Array(npc_ids));
|
||||
}
|
||||
}
|
||||
|
||||
fn read_object_text(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn read_first_object_text(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter().find_map(|key| read_object_text(object, key))
|
||||
}
|
||||
|
||||
fn insert_text_if_missing(object: &mut JsonMap<String, JsonValue>, key: &str, value: &str) {
|
||||
if read_object_text(object, key).is_none() {
|
||||
object.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn existing_character_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
|
||||
["playableNpcs", "storyNpcs"]
|
||||
.into_iter()
|
||||
.flat_map(|key| object_array_names(draft_profile.get(key)))
|
||||
.take(10)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn existing_landmark_names(draft_profile: &JsonMap<String, JsonValue>) -> Vec<String> {
|
||||
object_array_names(draft_profile.get("landmarks"))
|
||||
.into_iter()
|
||||
.take(10)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn object_array_names(value: Option<&JsonValue>) -> Vec<String> {
|
||||
value
|
||||
.and_then(JsonValue::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.get("name").and_then(JsonValue::as_str))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_anchor_summary(
|
||||
draft_profile: &JsonMap<String, JsonValue>,
|
||||
anchor_card_ids: &[String],
|
||||
) -> String {
|
||||
let selected = anchor_card_ids
|
||||
.iter()
|
||||
.filter_map(|card_id| find_anchor_summary(draft_profile, card_id))
|
||||
.collect::<Vec<_>>();
|
||||
if !selected.is_empty() {
|
||||
return selected.join(";");
|
||||
}
|
||||
draft_profile
|
||||
.get("summary")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("围绕当前世界底稿自然扩展。")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn find_anchor_summary(
|
||||
draft_profile: &JsonMap<String, JsonValue>,
|
||||
card_id: &str,
|
||||
) -> Option<String> {
|
||||
for key in ["playableNpcs", "storyNpcs", "landmarks", "threads"] {
|
||||
for entry in draft_profile.get(key)?.as_array()? {
|
||||
let object = entry.as_object()?;
|
||||
let id = object
|
||||
.get("id")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default();
|
||||
if id != card_id {
|
||||
continue;
|
||||
}
|
||||
let name = object
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default();
|
||||
let summary = object
|
||||
.get("summary")
|
||||
.or_else(|| object.get("description"))
|
||||
.or_else(|| object.get("publicMask"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.unwrap_or_default();
|
||||
return Some(format!("{name}:{summary}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn create_stable_id(prefix: &str, name: &str, index: usize) -> String {
|
||||
let slug = name
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch) {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
format!(
|
||||
"{prefix}-{}-{index}",
|
||||
if slug.is_empty() {
|
||||
"entry"
|
||||
} else {
|
||||
slug.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn character_expansion_prompt_keeps_node_contract_text() {
|
||||
let prompt = build_custom_world_agent_character_expansion_prompt(ExpansionPromptParams {
|
||||
world_name: "雾港归航",
|
||||
world_summary: "守灯人追查旧案。",
|
||||
creator_intent_summary: "悬疑航海",
|
||||
anchor_summary: "旧灯塔:灯火错位",
|
||||
existing_names: vec!["岑灯".to_string()],
|
||||
count: 2,
|
||||
prompt_seed: "补一个敌对角色",
|
||||
});
|
||||
|
||||
assert!(prompt.contains("返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。"));
|
||||
assert!(prompt.contains("threadIds 必须优先引用现有线程 id。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_entities_payload_injects_expected_key() {
|
||||
let payload = ExecuteCustomWorldAgentActionRequest {
|
||||
action: "generate_landmarks".to_string(),
|
||||
profile_id: None,
|
||||
draft_profile: None,
|
||||
legacy_result_profile: None,
|
||||
setting_text: None,
|
||||
card_id: None,
|
||||
sections: None,
|
||||
profile: None,
|
||||
count: Some(1),
|
||||
role_type: None,
|
||||
prompt_text: Some("补地点".to_string()),
|
||||
anchor_card_ids: None,
|
||||
role_ids: None,
|
||||
role_id: None,
|
||||
portrait_path: None,
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: None,
|
||||
scene_ids: None,
|
||||
scene_id: None,
|
||||
scene_kind: None,
|
||||
image_src: None,
|
||||
generated_scene_asset_id: None,
|
||||
generated_scene_prompt: None,
|
||||
generated_scene_model: None,
|
||||
checkpoint_id: None,
|
||||
};
|
||||
|
||||
let payload_json = build_generated_entities_action_payload_json(
|
||||
&payload,
|
||||
"generatedLandmarks",
|
||||
vec![json!({ "name": "沉船湾" })],
|
||||
)
|
||||
.expect("payload should build");
|
||||
let value = serde_json::from_str::<JsonValue>(&payload_json).expect("payload should parse");
|
||||
|
||||
assert_eq!(value.get("action"), Some(&json!("generate_landmarks")));
|
||||
assert_eq!(
|
||||
value
|
||||
.get("generatedLandmarks")
|
||||
.and_then(JsonValue::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_character_payload_fills_result_profile_fields() {
|
||||
let draft_profile = json!({
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [],
|
||||
"landmarks": []
|
||||
});
|
||||
let entities = normalize_generated_entities(
|
||||
"generate_characters",
|
||||
vec![json!({
|
||||
"name": "潮雾证人",
|
||||
"role": "旧案目击者",
|
||||
"publicMask": "总在码头边缘售卖旧航图。",
|
||||
"hiddenHook": "他记得沉钟第一次响起时失踪的人。",
|
||||
"relationToPlayer": "掌握玩家亲族旧案线索"
|
||||
})],
|
||||
draft_profile
|
||||
.as_object()
|
||||
.expect("draft profile should be object"),
|
||||
1,
|
||||
);
|
||||
let character = entities[0]
|
||||
.as_object()
|
||||
.expect("generated character should be object");
|
||||
|
||||
assert_eq!(
|
||||
character.get("description").and_then(JsonValue::as_str),
|
||||
Some("总在码头边缘售卖旧航图。")
|
||||
);
|
||||
assert_eq!(
|
||||
character.get("backstory").and_then(JsonValue::as_str),
|
||||
Some("他记得沉钟第一次响起时失踪的人。")
|
||||
);
|
||||
assert_eq!(
|
||||
character
|
||||
.get("relationshipHooks")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("掌握玩家亲族旧案线索")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_landmark_payload_fills_scene_profile_fields() {
|
||||
let draft_profile = json!({
|
||||
"playableNpcs": [],
|
||||
"storyNpcs": [{ "id": "character-witness", "name": "潮雾证人" }],
|
||||
"landmarks": []
|
||||
});
|
||||
let entities = normalize_generated_entities(
|
||||
"generate_landmarks",
|
||||
vec![json!({
|
||||
"name": "沉钟码头",
|
||||
"purpose": "玩家第一次追查沉钟旧案的入口。",
|
||||
"mood": "潮湿、压抑、灯火忽明忽暗。",
|
||||
"secret": "码头木桩下藏着改写航道的符牌。",
|
||||
"dangerLevel": "高",
|
||||
"characterIds": ["character-witness"]
|
||||
})],
|
||||
draft_profile
|
||||
.as_object()
|
||||
.expect("draft profile should be object"),
|
||||
1,
|
||||
);
|
||||
let landmark = entities[0]
|
||||
.as_object()
|
||||
.expect("generated landmark should be object");
|
||||
|
||||
assert!(
|
||||
landmark
|
||||
.get("description")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|text| text.contains("沉钟旧案") && text.contains("符牌"))
|
||||
);
|
||||
assert_eq!(
|
||||
landmark
|
||||
.get("sceneNpcIds")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(JsonValue::as_str),
|
||||
Some("character-witness")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,13 @@ mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
mod creation_agent_anchor_templates;
|
||||
mod custom_world;
|
||||
mod custom_world_agent_entities;
|
||||
mod custom_world_agent_turn;
|
||||
mod custom_world_ai;
|
||||
mod custom_world_foundation_draft;
|
||||
|
||||
@@ -6,6 +6,10 @@ use spacetime_client::{
|
||||
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
|
||||
};
|
||||
|
||||
use crate::creation_agent_anchor_templates::{
|
||||
get_creation_agent_anchor_template, render_anchor_question_block,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PuzzleAgentTurnRequest<'a> {
|
||||
pub llm_client: Option<&'a LlmClient>,
|
||||
@@ -59,13 +63,6 @@ const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创
|
||||
2. 当前进度 progressPercent
|
||||
3. 下一轮完整可用的 nextAnchorPack
|
||||
|
||||
拼图创作固定围绕 5 个视觉锚点:
|
||||
1. themePromise:题材承诺
|
||||
2. visualSubject:画面主体
|
||||
3. visualMood:视觉气质
|
||||
4. compositionHooks:拼图记忆点
|
||||
5. tagsAndForbidden:标签与禁忌
|
||||
|
||||
硬约束:
|
||||
1. 只能输出 JSON,不能输出代码块或解释
|
||||
2. nextAnchorPack 必须是完整对象,不能只输出 patch
|
||||
@@ -211,8 +208,12 @@ pub(crate) fn build_failed_finalize_record_input(
|
||||
}
|
||||
|
||||
fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String {
|
||||
let anchor_question_block = get_creation_agent_anchor_template("puzzle")
|
||||
.map(render_anchor_question_block)
|
||||
.unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string());
|
||||
format!(
|
||||
"当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
|
||||
"{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 = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
|
||||
|
||||
Reference in New Issue
Block a user