合并 master 并保留外部生成 worker 模式

合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新
保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径
补齐拼图后台图片生成队列轮询和运行态返回恢复
同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
This commit is contained in:
2026-06-09 16:55:32 +08:00
497 changed files with 66318 additions and 13329 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,
)
})
}

View File

@@ -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("雨夜猫街", "雨夜猫街主题背景");

View File

@@ -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(';')