feat: workerize external generation

This commit is contained in:
2026-06-05 17:29:08 +08:00
parent 5150925947
commit 8d54ea3374
60 changed files with 5285 additions and 700 deletions

View File

@@ -609,35 +609,6 @@ pub async fn execute_puzzle_agent_action(
"拼图 Agent action 开始执行"
);
let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| {
let state = state.clone();
let owner_user_id = owner_user_id.clone();
let error_message = error.body_text();
let session_id = compile_session_id.to_string();
let log_session_id = session_id.clone();
let log_owner_user_id = owner_user_id.clone();
async move {
let result = state
.spacetime_client()
.mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput {
session_id,
owner_user_id,
error_message,
failed_at_micros: now,
})
.await;
if let Err(error) = result {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %log_session_id,
owner_user_id = %log_owner_user_id,
message = %error,
"拼图草稿失败态回写失败,继续返回原始错误"
);
}
}
};
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
@@ -667,61 +638,88 @@ pub async fn execute_puzzle_agent_action(
Ok(next_session_id) => next_session_id,
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,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
payload.image_model.as_deref(),
now,
)
.await
},
)
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
&request_context,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
primary_reference_image_src,
now,
)
.await
let worker_payload = PuzzleCompileDraftWorkerPayload {
session_id: compile_session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
ai_redraw,
prompt_text: prompt_text.map(ToOwned::to_owned),
reference_image_src: primary_reference_image_src.map(ToOwned::to_owned),
image_model: payload.image_model.clone(),
requested_at_micros: now,
};
let session = match session {
Ok(session) => Ok(session),
Err(error) => {
mark_puzzle_compile_failure(&error, &compile_session_id).await;
Err(puzzle_error_response(
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:compile_puzzle_draft:{compile_session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_COMPILE_DRAFT_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id: compile_session_id.clone(),
request_label: "拼图首关草稿生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
error,
))
}
map_puzzle_client_error(error),
)
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", session.progress_percent.max(10)),
"failed" => ("failed", session.progress_percent),
_ => ("queued", session.progress_percent.max(5)),
};
(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
"已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "compile_puzzle_draft".to_string(),
status: status.to_string(),
phase_label: "首关拼图草稿".to_string(),
phase_detail: if ai_redraw {
"首关草稿生成已进入后台队列。".to_string()
} else {
"首关草稿编译已进入后台队列。".to_string()
},
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
session,
)
));
}
"save_puzzle_form_draft" => {
let seed_text = build_puzzle_form_seed_text_from_parts(
@@ -783,367 +781,205 @@ pub async fn execute_puzzle_agent_action(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_generated_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let mut target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let fallback_level_name = target_level.level_name.clone();
let prompt = resolve_puzzle_level_image_prompt(
payload.prompt_text.as_deref(),
&target_level.picture_description,
&draft.summary,
);
let should_auto_name_level = payload
.should_auto_name_level
.unwrap_or_else(|| target_level.level_name.trim().is_empty());
let mut generated_naming = if should_auto_name_level {
let naming = generate_puzzle_first_level_name(
&state,
target_level.picture_description.as_str(),
)
.await;
target_level.level_name = naming.level_name.clone();
target_level.ui_background_prompt = naming.ui_background_prompt.clone();
Some(naming)
} else {
None
};
let reference_image_sources = collect_puzzle_reference_image_sources(
payload.reference_image_src.as_deref(),
payload.reference_image_srcs.as_slice(),
payload.reference_image_asset_object_id.as_deref(),
payload.reference_image_asset_object_ids.as_slice(),
);
let primary_reference_image_src =
reference_image_sources.first().map(String::as_str);
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
let candidate_start_index = target_level.candidates.len();
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let mut candidates = if should_use_uploaded_puzzle_image_directly(
primary_reference_image_src,
ai_redraw,
) {
vec![
create_uploaded_puzzle_image_candidate(
&state,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src.expect("checked reference image"),
candidate_start_index,
)
.await?,
]
} else {
let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id);
generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
Some(profile_id.as_str()),
&session.session_id,
&target_level.level_name,
&prompt,
primary_reference_image_src,
ai_redraw,
payload.image_model.as_deref(),
1,
candidate_start_index,
)
.await
.map_err(map_puzzle_generation_endpoint_error)?
};
if candidates.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}),
));
}
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
&state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
.filter(|_| should_auto_name_level)
{
target_level.level_name = refined_naming.level_name.clone();
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt =
refined_naming.ui_background_prompt.clone();
}
generated_naming = Some(refined_naming);
}
let generated_level_name = target_level.level_name.clone();
let mut updated_levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
primary_reference_image_src,
);
for candidate in &mut candidates {
candidate.record.prompt = prompt.clone();
}
let selected_candidate = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图候选图生成结果为空",
}))
})?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
&state,
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let worker_payload = PuzzleGenerateImagesWorkerPayload {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
level_id: target_level_id.clone(),
prompt_text: payload.prompt_text.clone(),
reference_image_src: payload.reference_image_src.clone(),
reference_image_srcs: payload.reference_image_srcs.clone(),
reference_image_asset_object_id: payload.reference_image_asset_object_id.clone(),
reference_image_asset_object_ids: payload.reference_image_asset_object_ids.clone(),
image_model: payload.image_model.clone(),
ai_redraw: payload.ai_redraw,
should_auto_name_level: payload.should_auto_name_level,
work_title: payload.work_title.clone(),
work_description: payload.work_description.clone(),
picture_description: payload.picture_description.clone(),
summary: payload.summary.clone(),
theme_tags: payload.theme_tags.clone(),
levels_json,
requested_at_micros: now,
};
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图关卡图片生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let source_entity_id = target_level_id
.as_deref()
.map(|level_id| format!("{session_id}:{level_id}"))
.unwrap_or_else(|| session_id.clone());
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:generate_puzzle_images:{session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id,
request_label: "拼图关卡图片生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level,
&selected_candidate.downloaded_image,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
attach_selected_puzzle_candidate_to_levels(
&mut updated_levels,
target_level.level_id.as_str(),
&selected_candidate.record,
);
let levels_json_with_generated_name =
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
let candidates_json = serde_json::to_string(
&candidates
.iter()
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
.collect::<Vec<_>>(),
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图候选图序列化失败:{error}"),
}))
})?;
let save_result = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
// 中文注释VectorEngine/OSS 已生成真实图片时SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
let fallback_session = if should_auto_name_level {
apply_generated_puzzle_first_level_name_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
)
} else {
fallback_session
};
let mut fallback_session =
apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_levels_to_session_snapshot(
fallback_session,
updated_levels,
now,
),
target_level.level_id.as_str(),
candidates.into_records(),
primary_reference_image_src,
now,
);
if let Some(generated_naming) = generated_naming.as_ref() {
fallback_session =
apply_generated_puzzle_metadata_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
generated_naming,
fallback_level_name.as_str(),
now,
);
}
Ok(fallback_session)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", 35),
"failed" => ("failed", 0),
_ => ("queued", 8),
};
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "generate_puzzle_images".to_string(),
status: status.to_string(),
phase_label: "拼图图片生成".to_string(),
phase_detail: "关卡图片生成已进入后台队列。".to_string(),
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_images",
"拼图图片生成",
"已生成并替换当前拼图图片。",
session,
)
));
}
"generate_puzzle_ui_background" => {
let target_level_id = payload.level_id.clone();
let raw_prompt = payload
.prompt_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_default()
.to_string();
let levels_json = normalize_puzzle_levels_json_for_module(
payload.levels_json.as_deref(),
)
.map_err(|message| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
}))
});
let session = execute_billable_asset_operation_with_cost(
state.root_state(),
&owner_user_id,
"puzzle_ui_background_image",
&billing_asset_id,
PUZZLE_IMAGE_GENERATION_POINTS_COST,
async {
let levels_json = levels_json?;
let session = get_puzzle_session_for_image_generation(
&state,
session_id.clone(),
owner_user_id.clone(),
&payload,
levels_json.as_deref(),
now,
)
.await?;
let mut draft = session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
if let Some(levels_json) = levels_json.as_ref() {
draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?;
}
let target_level =
select_puzzle_level_for_api(&draft, target_level_id.as_deref())?;
let resolved_prompt = normalize_puzzle_ui_background_prompt(
raw_prompt.as_str(),
&draft,
&target_level,
);
let generated = generate_puzzle_ui_background_image(
&state,
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": message,
})),
)
})?;
let worker_payload = PuzzleGenerateUiBackgroundWorkerPayload {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
billing_asset_id: billing_asset_id.clone(),
level_id: target_level_id.clone(),
prompt_text: payload.prompt_text.clone(),
levels_json,
requested_at_micros: now,
};
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图 UI 背景图生成任务参数序列化失败:{error}"),
})),
)
})?;
let external_generation_job_id = build_prefixed_uuid_id("extgen-");
let source_entity_id = target_level_id
.as_deref()
.map(|level_id| format!("{session_id}:{level_id}"))
.unwrap_or_else(|| session_id.clone());
let job = state
.spacetime_client()
.enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput {
job_id: external_generation_job_id.clone(),
dedupe_key: format!(
"puzzle:generate_puzzle_ui_background:{session_id}:{external_generation_job_id}"
),
job_kind: PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND.to_string(),
owner_user_id: owner_user_id.clone(),
source_module: "puzzle".to_string(),
source_entity_id,
request_label: "拼图 UI 背景图生成".to_string(),
request_payload_json,
max_attempts: 1,
available_at_micros: now,
created_at_micros: now,
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
owner_user_id.as_str(),
&session.session_id,
&target_level.level_name,
resolved_prompt.as_str(),
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
let save_result = state
.spacetime_client()
.save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json,
prompt: resolved_prompt.clone(),
image_src: generated.image_src.clone(),
image_object_key: Some(generated.object_key.clone()),
saved_at_micros: now,
})
.await;
match save_result {
Ok(session) => Ok(session),
Err(error)
if should_skip_asset_operation_billing_for_connectivity(&error) =>
{
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %session.session_id,
owner_user_id = %owner_user_id,
error = %error,
"拼图 UI 背景图生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
);
let fallback_session =
replace_puzzle_session_draft_snapshot(session, draft, now);
Ok(apply_generated_puzzle_ui_background_to_session_snapshot(
fallback_session,
target_level.level_id.as_str(),
resolved_prompt,
generated.image_src,
Some(generated.object_key),
now,
))
}
Err(error) => Err(map_puzzle_client_error(error)),
}
})?;
let session = state
.spacetime_client()
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
let (status, progress) = match job.status.as_str() {
"completed" => ("completed", 100),
"running" => ("running", session.progress_percent.max(55)),
"failed" => ("failed", session.progress_percent),
_ => ("queued", session.progress_percent.max(12)),
};
return Ok(json_success_body(
Some(&request_context),
PuzzleAgentActionResponse {
operation: PuzzleAgentOperationResponse {
operation_id: job.job_id,
operation_type: "generate_puzzle_ui_background".to_string(),
status: status.to_string(),
phase_label: "UI 背景图生成".to_string(),
phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(),
progress,
error: job.last_error_message,
},
session: map_puzzle_agent_session_response(session),
},
)
.await
.map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
});
(
"generate_puzzle_ui_background",
"UI 背景图生成",
"已生成拼图 UI 背景图。",
session,
)
));
}
"generate_puzzle_tags" => {
let work_title = payload