合并 master 并保留外部生成 worker 模式
合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新 保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径 补齐拼图后台图片生成队列轮询和运行态返回恢复 同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
This commit is contained in:
@@ -137,6 +137,10 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
Ok(replacement.session_id)
|
||||
}
|
||||
|
||||
fn default_puzzle_image_generation_points_cost() -> u64 {
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PuzzleCompileDraftWorkerPayload {
|
||||
@@ -144,6 +148,8 @@ pub(crate) struct PuzzleCompileDraftWorkerPayload {
|
||||
pub owner_user_id: String,
|
||||
pub billing_asset_id: String,
|
||||
pub ai_redraw: bool,
|
||||
#[serde(default = "default_puzzle_image_generation_points_cost")]
|
||||
pub billing_points_cost: u64,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -166,7 +172,7 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
|
||||
&payload.owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&payload.billing_asset_id,
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
payload.billing_points_cost,
|
||||
async {
|
||||
compile_puzzle_draft_with_initial_cover(
|
||||
state,
|
||||
@@ -198,7 +204,32 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
|
||||
};
|
||||
|
||||
match session {
|
||||
Ok(session) => Ok(session),
|
||||
Ok(session) => {
|
||||
if session
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.generation_status == "ready")
|
||||
{
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state.root_state(),
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: payload.owner_user_id.clone(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()),
|
||||
status: GenerationResultSubscribeMessageStatus::Succeeded,
|
||||
consumed_points: if payload.ai_redraw {
|
||||
payload.billing_points_cost
|
||||
} else {
|
||||
0
|
||||
},
|
||||
completed_at_micros: current_utc_micros(),
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
Err(error) => {
|
||||
match mark_puzzle_compile_failure_for_worker(
|
||||
state,
|
||||
@@ -211,6 +242,19 @@ pub(crate) async fn execute_puzzle_compile_draft_worker_job(
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
send_generation_result_subscribe_message_after_completion(
|
||||
state.root_state(),
|
||||
GenerationResultSubscribeMessage {
|
||||
owner_user_id: payload.owner_user_id.clone(),
|
||||
task_name: Some("拼图".to_string()),
|
||||
work_name: None,
|
||||
status: GenerationResultSubscribeMessageStatus::Failed,
|
||||
consumed_points: 0,
|
||||
completed_at_micros: now,
|
||||
page: Some("/pages/web-view/index".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error))
|
||||
}
|
||||
Err(mark_error) => {
|
||||
@@ -425,13 +469,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
|
||||
levels,
|
||||
form_draft: None,
|
||||
};
|
||||
let stage = if is_puzzle_session_snapshot_publish_ready(&draft) {
|
||||
"ready_to_publish"
|
||||
} else {
|
||||
"image_refining"
|
||||
};
|
||||
|
||||
Ok(PuzzleAgentSessionRecord {
|
||||
session_id: session_id.to_string(),
|
||||
seed_text: String::new(),
|
||||
current_turn: 0,
|
||||
progress_percent: 94,
|
||||
stage: "ready_to_publish".to_string(),
|
||||
stage: stage.to_string(),
|
||||
anchor_pack,
|
||||
draft: Some(draft),
|
||||
messages: Vec::new(),
|
||||
@@ -1095,6 +1144,7 @@ pub(crate) fn attach_selected_puzzle_candidate_to_levels(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -1160,6 +1210,7 @@ pub(crate) fn build_puzzle_ui_background_generation_prompt(
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn attach_puzzle_level_ui_background(
|
||||
levels: &mut [PuzzleDraftLevelRecord],
|
||||
level_id: &str,
|
||||
@@ -1201,27 +1252,6 @@ pub(crate) fn attach_puzzle_level_asset_bundle(
|
||||
level.ui_background_image_object_key = Some(generated.level_background.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
|
||||
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
|
||||
let generated = generate_puzzle_ui_background_image(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
target_level.level_name.as_str(),
|
||||
prompt.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok((prompt, generated))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
@@ -1309,7 +1339,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
let compiled_session = state
|
||||
.spacetime_client()
|
||||
.compile_puzzle_agent_draft_with_external_generation_guard(
|
||||
session_id.clone(),
|
||||
session_id,
|
||||
owner_user_id.clone(),
|
||||
now,
|
||||
external_generation_guard.job_id.clone(),
|
||||
@@ -1318,6 +1348,32 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_compile_error)?;
|
||||
|
||||
generate_puzzle_initial_cover_from_compiled_session(
|
||||
state,
|
||||
request_context,
|
||||
compiled_session,
|
||||
owner_user_id,
|
||||
prompt_text,
|
||||
reference_image_src,
|
||||
image_model,
|
||||
now,
|
||||
external_generation_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
compiled_session: PuzzleAgentSessionRecord,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
reference_image_src: Option<&str>,
|
||||
image_model: Option<&str>,
|
||||
now: i64,
|
||||
external_generation_guard: &ExternalGenerationWriteLeaseGuard,
|
||||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
@@ -1458,7 +1514,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
"message": format!("拼图候选图序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let saved_session = state
|
||||
let (saved_session, save_used_fallback) = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
@@ -1472,7 +1528,40 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map(|session| (session, false))
|
||||
.or_else(|error| {
|
||||
if is_spacetimedb_connectivity_app_error(&error) {
|
||||
// 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
reference_image_src,
|
||||
now,
|
||||
);
|
||||
Ok((session, true))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
match state
|
||||
.spacetime_client()
|
||||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||
@@ -1509,10 +1598,13 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
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,
|
||||
@@ -1729,7 +1821,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
"message": format!("拼图上传图候选序列化失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let saved_session = state
|
||||
let (saved_session, save_used_fallback) = state
|
||||
.spacetime_client()
|
||||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||||
session_id: compiled_session.session_id.clone(),
|
||||
@@ -1743,7 +1835,39 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
external_generation_lease_token: external_generation_guard.lease_token.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?;
|
||||
.map_err(map_puzzle_client_error)
|
||||
.map(|session| (session, false))
|
||||
.or_else(|error| {
|
||||
if is_spacetimedb_connectivity_app_error(&error) {
|
||||
tracing::warn!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compiled_session.session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
message = %error.body_text(),
|
||||
"拼图上传图草稿回写不可用,降级返回本地快照"
|
||||
);
|
||||
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
apply_generated_puzzle_levels_to_session_snapshot(
|
||||
apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
compiled_session.clone(),
|
||||
target_level.level_id.as_str(),
|
||||
generated_level_name.as_str(),
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
),
|
||||
updated_levels.clone(),
|
||||
now,
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
vec![candidate.clone()],
|
||||
reference_image_src,
|
||||
now,
|
||||
);
|
||||
Ok((session, true))
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
})?;
|
||||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||||
match state
|
||||
.spacetime_client()
|
||||
@@ -1781,6 +1905,9 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
fallback_level_name.as_str(),
|
||||
now,
|
||||
);
|
||||
if save_used_fallback {
|
||||
return Ok(saved_session);
|
||||
}
|
||||
match state
|
||||
.spacetime_client()
|
||||
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
|
||||
@@ -1811,7 +1938,6 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
@@ -1858,13 +1984,33 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(94);
|
||||
session.stage = "ready_to_publish".to_string();
|
||||
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
|
||||
"ready_to_publish".to_string()
|
||||
} else {
|
||||
"image_refining".to_string()
|
||||
};
|
||||
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply_generated_puzzle_levels_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
levels: Vec<PuzzleDraftLevelRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
if levels.is_empty() {
|
||||
return session;
|
||||
}
|
||||
draft.levels = levels;
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
@@ -1917,6 +2063,45 @@ pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_metadata_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
previous_level_name: &str,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
|
||||
draft.levels[target_index].level_name = metadata.level_name.clone();
|
||||
if metadata.ui_background_prompt.is_some() {
|
||||
draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone();
|
||||
}
|
||||
|
||||
if target_index == 0 {
|
||||
apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft,
|
||||
metadata,
|
||||
previous_level_name,
|
||||
updated_at_micros,
|
||||
);
|
||||
} else {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft(
|
||||
draft: &mut PuzzleResultDraftRecord,
|
||||
metadata: &PuzzleLevelNaming,
|
||||
@@ -1966,3 +2151,45 @@ pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResu
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_puzzle_session_draft_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
draft: PuzzleResultDraftRecord,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
session.draft = Some(draft);
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot(
|
||||
mut session: PuzzleAgentSessionRecord,
|
||||
target_level_id: &str,
|
||||
prompt: String,
|
||||
image_src: String,
|
||||
image_object_key: Option<String>,
|
||||
updated_at_micros: i64,
|
||||
) -> PuzzleAgentSessionRecord {
|
||||
let Some(draft) = session.draft.as_mut() else {
|
||||
return session;
|
||||
};
|
||||
let Some(target_index) = draft
|
||||
.levels
|
||||
.iter()
|
||||
.position(|level| level.level_id == target_level_id)
|
||||
.or_else(|| (!draft.levels.is_empty()).then_some(0))
|
||||
else {
|
||||
return session;
|
||||
};
|
||||
let level = &mut draft.levels[target_index];
|
||||
level.ui_background_prompt = Some(prompt);
|
||||
level.ui_background_image_src = Some(image_src);
|
||||
level.ui_background_image_object_key = image_object_key;
|
||||
if target_index == 0 {
|
||||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||||
}
|
||||
session.progress_percent = session.progress_percent.max(96);
|
||||
session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string());
|
||||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||||
session
|
||||
}
|
||||
|
||||
@@ -612,6 +612,16 @@ pub async fn execute_puzzle_agent_action(
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let puzzle_draft_generation_points_cost = if ai_redraw {
|
||||
crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
|
||||
state.root_state(),
|
||||
"puzzle",
|
||||
PUZZLE_IMAGE_GENERATION_POINTS_COST,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
@@ -643,6 +653,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
billing_asset_id: billing_asset_id.clone(),
|
||||
ai_redraw,
|
||||
billing_points_cost: puzzle_draft_generation_points_cost,
|
||||
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(),
|
||||
@@ -735,6 +746,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
.spacetime_client()
|
||||
.get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map(mark_puzzle_initial_generation_started_snapshot)
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
@@ -1296,7 +1308,6 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
|
||||
let session = session?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
|
||||
@@ -396,49 +396,6 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
None,
|
||||
);
|
||||
PuzzleWorkSummaryResponse {
|
||||
work_id: item.work_id,
|
||||
profile_id: item.profile_id,
|
||||
owner_user_id: item.owner_user_id,
|
||||
source_session_id: item.source_session_id,
|
||||
author_display_name: author.display_name,
|
||||
work_title: item.work_title,
|
||||
work_description: item.work_description,
|
||||
level_name: item.level_name,
|
||||
summary: item.summary,
|
||||
theme_tags: item.theme_tags,
|
||||
cover_image_src: item.cover_image_src,
|
||||
cover_asset_id: item.cover_asset_id,
|
||||
publication_status: item.publication_status,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
play_count: item.play_count,
|
||||
remix_count: item.remix_count,
|
||||
like_count: item.like_count,
|
||||
recent_play_count_7d: item.recent_play_count_7d,
|
||||
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||
point_incentive_claimable_points: item
|
||||
.point_incentive_total_half_points
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_public_work_puzzle_gallery_card_response(
|
||||
state: &PuzzleApiState,
|
||||
item: spacetime_client::PublicWorkGalleryEntryRecord,
|
||||
|
||||
@@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
|
||||
image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
|| object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
|
||||
!draft.work_title.trim().is_empty()
|
||||
&& !draft.work_description.trim().is_empty()
|
||||
@@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.level_scene_image_src,
|
||||
&level.level_scene_image_object_key,
|
||||
)
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.ui_spritesheet_image_src,
|
||||
&level.ui_spritesheet_image_object_key,
|
||||
)
|
||||
&& has_required_puzzle_asset_ref(
|
||||
&level.level_background_image_src,
|
||||
&level.level_background_image_object_key,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ fn puzzle_vector_engine_create_request_never_embeds_reference_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -197,15 +196,11 @@ fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() {
|
||||
fn puzzle_vector_engine_create_request_never_embeds_reference_payload() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
bytes: b"test".to_vec(),
|
||||
signed_read_url: Some(
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -474,7 +469,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
.expect("fallback session");
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(session.stage, "ready_to_publish");
|
||||
assert_eq!(session.stage, "image_refining");
|
||||
assert_eq!(draft.work_title, "暖灯猫街作品");
|
||||
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
|
||||
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
|
||||
@@ -484,6 +479,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() {
|
||||
let levels_json = serde_json::to_string(&vec![json!({
|
||||
"level_id": "puzzle-level-1",
|
||||
"level_name": "雨夜猫街",
|
||||
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||
"candidates": [],
|
||||
"selected_candidate_id": null,
|
||||
"cover_image_src": "/generated/puzzle/cover.png",
|
||||
"cover_asset_id": "asset-cover",
|
||||
"level_scene_image_src": "/generated/puzzle/level-scene.png",
|
||||
"level_scene_image_object_key": "generated/puzzle/level-scene.png",
|
||||
"ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png",
|
||||
"ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png",
|
||||
"level_background_image_src": "/generated/puzzle/level-background.png",
|
||||
"level_background_image_object_key": "generated/puzzle/level-background.png",
|
||||
"generation_status": "ready",
|
||||
})])
|
||||
.expect("levels json");
|
||||
let payload = ExecutePuzzleAgentActionRequest {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
should_auto_name_level: None,
|
||||
candidate_id: None,
|
||||
level_id: Some("puzzle-level-1".to_string()),
|
||||
work_title: Some("暖灯猫街作品".to_string()),
|
||||
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||
picture_description: None,
|
||||
level_name: None,
|
||||
summary: Some("当前关卡画面。".to_string()),
|
||||
theme_tags: Some(vec![
|
||||
"猫咪".to_string(),
|
||||
"雨夜".to_string(),
|
||||
"灯牌".to_string(),
|
||||
]),
|
||||
levels_json: Some(levels_json.clone()),
|
||||
};
|
||||
|
||||
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||
"puzzle-session-1",
|
||||
&payload,
|
||||
Some(levels_json.as_str()),
|
||||
1_713_686_401_234_567,
|
||||
)
|
||||
.expect("fallback session");
|
||||
|
||||
assert_eq!(session.stage, "ready_to_publish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generate_images_worker_payload_keeps_action_snapshot() {
|
||||
let raw_levels_json = serde_json::to_string(&vec![json!({
|
||||
@@ -685,7 +736,6 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
@@ -1082,6 +1132,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("雨夜猫街", "雨夜猫街主题背景");
|
||||
|
||||
@@ -45,7 +45,6 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -318,10 +317,10 @@ pub(crate) fn build_puzzle_downloaded_image_reference(
|
||||
mime_type: image.mime_type.clone(),
|
||||
bytes_len: image.bytes.len(),
|
||||
bytes: image.bytes.clone(),
|
||||
signed_read_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
image_model: PuzzleImageModel,
|
||||
prompt: &str,
|
||||
@@ -330,7 +329,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
let body = Map::from_iter([
|
||||
let body = serde_json::Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -415,32 +414,6 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
@@ -463,6 +436,7 @@ pub(crate) fn should_use_puzzle_reference_image_generation(
|
||||
use_reference_image_generation && has_puzzle_reference_image(reference_image_src)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
@@ -525,7 +499,6 @@ pub(crate) async fn resolve_puzzle_reference_image(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -758,7 +731,6 @@ async fn download_signed_puzzle_reference_image(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1075,47 +1047,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_puzzle_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_strings_by_key(
|
||||
payload: &Value,
|
||||
target_key: &str,
|
||||
results: &mut Vec<String>,
|
||||
) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_puzzle_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
if key == target_key {
|
||||
collect_puzzle_string_values(value, results);
|
||||
}
|
||||
collect_puzzle_strings_by_key(value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::String(text) => results.push(text.to_string()),
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_puzzle_string_values(item, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
|
||||
Reference in New Issue
Block a user