fix: decouple puzzle draft generation lifecycle

This commit is contained in:
kdletters
2026-06-05 23:21:55 +08:00
parent 60709395d0
commit 8e1a62d130
8 changed files with 350 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, HashSet},
sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
};
@@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
fn puzzle_background_compile_tasks() -> &'static Mutex<HashSet<String>> {
PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
}
fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
match puzzle_background_compile_tasks().lock() {
Ok(mut tasks) => tasks.insert(session_id.to_string()),
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
error = %error,
"拼图后台生成任务注册表锁已损坏,允许本次任务继续"
);
true
}
}
}
fn unregister_puzzle_background_compile_task(session_id: &str) {
match puzzle_background_compile_tasks().lock() {
Ok(mut tasks) => {
tasks.remove(session_id);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id,
error = %error,
"拼图后台生成任务注册表解锁失败,忽略清理"
);
}
}
}
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
value
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn mark_puzzle_initial_generation_started_snapshot(
mut session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionRecord {
session.stage = "image_refining".to_string();
session.progress_percent = session.progress_percent.max(88);
if let Some(draft) = session.draft.as_mut() {
let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
if let Some(primary_level) = draft.levels.first_mut() {
if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
primary_level.generation_status = "generating".to_string();
}
draft.generation_status = primary_level.generation_status.clone();
draft.candidates = primary_level.candidates.clone();
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
draft.cover_image_src = primary_level.cover_image_src.clone();
draft.cover_asset_id = primary_level.cover_asset_id.clone();
} else if draft_needs_cover {
draft.generation_status = "generating".to_string();
}
}
session
}
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}

View File

@@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
.or_else(|| levels.first())
}
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
session_id: String,
compiled_session: PuzzleAgentSessionRecord,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
session_id,
session_id: compiled_session.session_id.clone(),
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id: selected_candidate_id,

View File

@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
failed_at_micros: current_utc_micros(),
})
.await;
if let Err(error) = result {
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
Err(response) => return Err(response),
};
let session = if ai_redraw {
execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_initial_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
compile_puzzle_draft_with_initial_cover(
&state,
&request_context,
if !try_register_puzzle_background_compile_task(&compile_session_id) {
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compile_session_id,
owner_user_id = %owner_user_id,
"拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
);
state
.spacetime_client()
.get_puzzle_agent_session(
compile_session_id.clone(),
owner_user_id.clone(),
)
.await
.map(mark_puzzle_initial_generation_started_snapshot)
.map_err(map_puzzle_client_error)
} else {
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
.await
},
)
.await
.map_err(map_puzzle_compile_error);
match compiled_session {
Ok(compiled_session) => {
let response_session =
mark_puzzle_initial_generation_started_snapshot(
compiled_session.clone(),
);
let background_state = state.clone();
let background_request_context = request_context.clone();
let background_session_id = compile_session_id.clone();
let background_owner_user_id = owner_user_id.clone();
let background_prompt_text = prompt_text.map(str::to_string);
let background_reference_image_src =
primary_reference_image_src.map(str::to_string);
let background_image_model = payload.image_model.clone();
let background_billing_asset_id =
format!("{background_session_id}:compile_puzzle_draft");
tokio::spawn(async move {
let operation_owner_user_id =
background_owner_user_id.clone();
let background_root_state =
background_state.root_state().clone();
let operation_state = background_state.clone();
let result = execute_billable_asset_operation_with_cost(
&background_root_state,
&background_owner_user_id,
"puzzle_initial_image",
&background_billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async move {
generate_puzzle_initial_cover_from_compiled_session(
&operation_state,
&background_request_context,
compiled_session,
operation_owner_user_id,
background_prompt_text.as_deref(),
background_reference_image_src.as_deref(),
background_image_model.as_deref(),
current_utc_micros(),
)
.await
},
)
.await;
match result {
Ok(session) => {
tracing::info!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %background_owner_user_id,
"拼图首图后台生成任务完成"
);
}
Err(error) => {
let error_message = error.body_text();
let failure_result = background_state
.spacetime_client()
.mark_puzzle_draft_generation_failed(
PuzzleDraftCompileFailureRecordInput {
session_id: background_session_id.clone(),
owner_user_id: background_owner_user_id
.clone(),
error_message: error_message.clone(),
failed_at_micros: current_utc_micros(),
},
)
.await;
if let Err(mark_error) = failure_result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %background_session_id,
owner_user_id = %background_owner_user_id,
message = %mark_error,
"拼图首图后台生成失败态回写失败"
);
}
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %background_session_id,
owner_user_id = %background_owner_user_id,
message = %error_message,
"拼图首图后台生成任务失败"
);
}
}
unregister_puzzle_background_compile_task(
&background_session_id,
);
});
Ok(response_session)
}
Err(error) => {
unregister_puzzle_background_compile_task(&compile_session_id);
Err(error)
}
}
}
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿"
"已编译首关草稿,并启动首关画面和 UI 资产后台生成"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
},

View File

@@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
);
}
#[test]
fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
let mut session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
current_turn: 1,
progress_percent: 88,
stage: "draft_ready".to_string(),
anchor_pack: test_puzzle_anchor_pack_record(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
{
let draft = session.draft.as_mut().expect("draft");
draft.generation_status = "idle".to_string();
draft.levels[0].generation_status = "idle".to_string();
draft.levels[0].cover_image_src = None;
draft.levels[0].cover_asset_id = None;
}
let session = mark_puzzle_initial_generation_started_snapshot(session);
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "image_refining");
assert_eq!(draft.generation_status, "generating");
assert_eq!(draft.levels[0].generation_status, "generating");
assert!(draft.cover_image_src.is_none());
assert!(draft.levels[0].cover_image_src.is_none());
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");