Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-05 11:31:50 +08:00
parent 100fee7e7a
commit 995661e7cc
299 changed files with 13805 additions and 1429 deletions

View File

@@ -119,6 +119,14 @@ use crate::{
put_runtime_snapshot, resume_profile_save_archive,
},
runtime_settings::{get_runtime_settings, put_runtime_settings},
square_hole::{
compile_square_hole_agent_draft, create_square_hole_agent_session, delete_square_hole_work,
drop_square_hole_shape, execute_square_hole_agent_action, finish_square_hole_time_up,
get_square_hole_agent_session, get_square_hole_run, get_square_hole_work_detail,
get_square_hole_works, list_square_hole_gallery, publish_square_hole_work,
put_square_hole_work, restart_square_hole_run, start_square_hole_run, stop_square_hole_run,
stream_square_hole_agent_message, submit_square_hole_agent_message,
},
state::AppState,
story_battles::{
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
@@ -829,6 +837,119 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions",
post(create_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}",
get(get_square_hole_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages",
post(submit_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/messages/stream",
post(stream_square_hole_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/actions",
post(execute_square_hole_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/sessions/{session_id}/compile",
post(compile_square_hole_agent_draft).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works",
get(get_square_hole_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}",
get(get_square_hole_work_detail)
.patch(put_square_hole_work)
.put(put_square_hole_work)
.delete(delete_square_hole_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/square-hole/works/{profile_id}/publish",
post(publish_square_hole_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/gallery",
get(list_square_hole_gallery),
)
.route(
"/api/runtime/square-hole/works/{profile_id}/runs",
post(start_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}",
get(get_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/drop",
post(drop_square_hole_shape).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/stop",
post(stop_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/restart",
post(restart_square_hole_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/square-hole/runs/{run_id}/time-up",
post(finish_square_hole_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session)

View File

@@ -64,8 +64,12 @@ where
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
CreationAgentJsonTurnFailure::Stream(error) => {
tracing::warn!(
error = %error,
"创作 Agent 流式 LLM 请求失败"
);
build_error(format!("{}{error}", messages.generation_failed))
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})

View File

@@ -61,6 +61,8 @@ mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod session_client;
mod square_hole;
mod square_hole_agent_turn;
mod state;
mod story_battles;
mod story_sessions;

View File

@@ -4,6 +4,7 @@ pub(crate) mod character_visual;
pub(crate) mod puzzle;
pub(crate) mod rpg;
pub(crate) mod scene_background;
pub(crate) mod square_hole;
pub(crate) use rpg::agent_chat;
pub(crate) use rpg::foundation_draft;

View File

@@ -0,0 +1,164 @@
use serde_json::{Value as JsonValue, json};
use spacetime_client::{SquareHoleAgentMessageRecord, SquareHoleAgentSessionRecord};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
/// 方洞挑战共创 Agent 的系统提示词。
///
/// 这里只定义模型职责与输出约束,具体的模型调用、解析和写库由方洞 Agent turn 负责。
pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“方洞挑战”竖屏玩法的中文创意策划。
你要把用户灵感收束成一个反直觉形状分拣小游戏:玩家会本能把形状投入对应洞口,但真实规则可能让所有形状都优先进入方洞,形成类似参考视频“所有东西都进方洞”的喜剧反差。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextConfig
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextConfig 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“结构”“JSON”“后端”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量和难度,不要继续提问
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
7. progressPercent 范围只能是 0 到 100
8. shapeCount 只能是 6 到 24 的整数difficulty 只能是 1 到 10 的整数
"#;
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextConfig": {
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4
}
}"#;
pub(crate) const SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。";
/// 方洞挑战草稿生成对话提示词脚本。
///
/// 方洞首版只需要四个可写回 SpacetimeDB 的配置项,因此提示词直接围绕配置收束,
/// 不在模型输出层引入额外锚点,避免和当前持久化 schema 产生漂移。
pub(crate) fn build_square_hole_agent_prompt(
session: &SquareHoleAgentSessionRecord,
quick_fill_requested: bool,
) -> String {
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前方洞挑战方向里的题材、反差规则、形状数量和难度",
"不要要求用户再提供洞口、形状、演出或难度信息",
"输出完整 nextConfig直接补齐空缺或仍过于泛化的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,优先体现方洞优先或类似反直觉逻辑。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. 用户给出明确方向时优先吸收并推进,不要机械问完四个问题。\n\n{contract}",
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 { "" },
current_config = serialize_square_hole_session_config(session),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = SQUARE_HOLE_AGENT_OUTPUT_CONTRACT,
)
}
fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String {
serde_json::to_string_pretty(&json!({
"themeText": session.config.theme_text,
"twistRule": session.config.twist_rule,
"shapeCount": session.config.shape_count,
"difficulty": session.config.difficulty,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn build_chat_history(messages: &[SquareHoleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::build_square_hole_agent_prompt;
fn message(role: &str, text: &str) -> spacetime_client::SquareHoleAgentMessageRecord {
spacetime_client::SquareHoleAgentMessageRecord {
id: format!("message-{role}"),
role: role.to_string(),
kind: "chat".to_string(),
text: text.to_string(),
created_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "积木纸箱"),
twist_rule: anchor("twistRule", "反差规则", ""),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "积木纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
},
draft: None,
messages: vec![message("user", "做成办公室文具版")],
last_assistant_reply: Some("这次可以从办公室文具题材开始。".to_string()),
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn quick_fill_prompt_requires_complete_config() {
let prompt = build_square_hole_agent_prompt(&session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("nextConfig"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
use module_square_hole::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT,
};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use spacetime_client::{SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentSessionRecord};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::square_hole::{
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT, SQUARE_HOLE_AGENT_SYSTEM_PROMPT,
build_square_hole_agent_prompt,
};
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a SquareHoleAgentSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub config_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnError {
message: String,
}
impl SquareHoleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for SquareHoleAgentTurnError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SquareHoleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_config: SquareHoleAgentConfigOutput,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentConfigOutput {
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
}
pub(crate) async fn run_square_hole_agent_turn<F>(
request: SquareHoleAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<SquareHoleAgentTurnResult, SquareHoleAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_square_hole_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{SQUARE_HOLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT,
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "方洞挑战聊天生成失败,请稍后重试。",
parse_failed: "方洞挑战聊天结果解析失败,请稍后重试。",
},
on_reply_update,
SquareHoleAgentTurnError::new,
)
.await?;
let output = parse_model_output(&turn_output.parsed, request.session)?;
let progress_percent = if request.quick_fill_requested {
100
} else {
output.progress_percent.min(100)
};
Ok(SquareHoleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_stage(progress_percent),
progress_percent,
config_json: serde_json::to_string(&output.next_config)
.map_err(|_| SquareHoleAgentTurnError::new("方洞挑战配置序列化失败。"))?,
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: SquareHoleAgentTurnResult,
updated_at_micros: i64,
) -> SquareHoleAgentMessageFinalizeRecordInput {
SquareHoleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
config_json: Some(result.config_json),
progress_percent: result.progress_percent,
stage: result.stage,
updated_at_micros,
error_message: result.error_message,
}
}
fn parse_model_output(
parsed: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentModelOutput, SquareHoleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(session.progress_percent);
let next_config_value = parsed
.get("nextConfig")
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少 nextConfig。"))?;
let next_config = parse_model_config(next_config_value, session)?;
Ok(SquareHoleAgentModelOutput {
reply_text,
progress_percent,
next_config,
})
}
fn parse_model_config(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentConfigOutput, SquareHoleAgentTurnError> {
if !value.is_object() {
return Err(SquareHoleAgentTurnError::new(
"方洞挑战聊天结果中的 nextConfig 必须是对象。",
));
}
Ok(SquareHoleAgentConfigOutput {
theme_text: read_text_field(value, "themeText")
.unwrap_or_else(|| session.config.theme_text.clone()),
twist_rule: read_text_field(value, "twistRule")
.unwrap_or_else(|| session.config.twist_rule.clone()),
shape_count: read_u32_field(value, "shapeCount")
.unwrap_or(session.config.shape_count)
.clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT),
difficulty: read_u32_field(value, "difficulty")
.unwrap_or(session.config.difficulty)
.clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY),
})
}
fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
value
.get(field_name)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
fn read_u32_field(value: &JsonValue, field_name: &str) -> Option<u32> {
value
.get(field_name)
.and_then(JsonValue::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
fn resolve_stage(progress_percent: u32) -> String {
if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{parse_model_output, resolve_stage};
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "纸箱"),
twist_rule: anchor("twistRule", "反差规则", "方洞万能"),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
},
draft: None,
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn parse_model_output_accepts_camel_case_config_contract() {
let model_output = json!({
"replyText": "可以,把办公室文具都做成会被方洞吞进去的挑战。",
"progressPercent": 86,
"nextConfig": {
"themeText": "办公室文具",
"twistRule": "所有文具最终都优先进入方洞",
"shapeCount": 14,
"difficulty": 6
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(
output.reply_text,
"可以,把办公室文具都做成会被方洞吞进去的挑战。"
);
assert_eq!(output.progress_percent, 86);
assert_eq!(output.next_config.theme_text, "办公室文具");
assert_eq!(output.next_config.twist_rule, "所有文具最终都优先进入方洞");
assert_eq!(output.next_config.shape_count, 14);
assert_eq!(output.next_config.difficulty, 6);
}
#[test]
fn parse_model_output_clamps_numeric_config() {
let model_output = json!({
"replyText": "我先把数字压到可试玩范围里。",
"progressPercent": 120,
"nextConfig": {
"themeText": "霓虹积木",
"twistRule": "方洞优先",
"shapeCount": 99,
"difficulty": 0
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(output.progress_percent, 100);
assert_eq!(output.next_config.shape_count, 24);
assert_eq!(output.next_config.difficulty, 1);
}
#[test]
fn resolve_stage_switches_to_compile_only_at_complete_progress() {
assert_eq!(resolve_stage(99), "Collecting");
assert_eq!(resolve_stage(100), "ReadyToCompile");
}
}