feat: workerize external generation
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user