Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
1944 lines
70 KiB
Rust
1944 lines
70 KiB
Rust
use super::*;
|
||
|
||
pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String {
|
||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||
title: None,
|
||
work_description: None,
|
||
picture_description: payload
|
||
.picture_description
|
||
.as_deref()
|
||
.or(payload.seed_text.as_deref()),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_form_seed_text_from_parts(
|
||
title: Option<&str>,
|
||
work_description: Option<&str>,
|
||
picture_description: Option<&str>,
|
||
) -> String {
|
||
build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
|
||
title,
|
||
work_description,
|
||
picture_description,
|
||
})
|
||
}
|
||
|
||
pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
payload: &ExecutePuzzleAgentActionRequest,
|
||
now: i64,
|
||
) -> Result<String, Response> {
|
||
let seed_text = build_puzzle_form_seed_text_from_parts(
|
||
None,
|
||
None,
|
||
payload
|
||
.picture_description
|
||
.as_deref()
|
||
.or(payload.prompt_text.as_deref()),
|
||
);
|
||
if seed_text.trim().is_empty() {
|
||
return Ok(session_id.to_string());
|
||
}
|
||
|
||
let save_result = state
|
||
.spacetime_client()
|
||
.save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput {
|
||
session_id: session_id.to_string(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
seed_text: seed_text.clone(),
|
||
saved_at_micros: now,
|
||
})
|
||
.await
|
||
.map(|_| ());
|
||
match save_result {
|
||
Ok(()) => Ok(session_id.to_string()),
|
||
Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => {
|
||
create_seeded_puzzle_session_when_form_save_missing(
|
||
state,
|
||
request_context,
|
||
session_id,
|
||
owner_user_id,
|
||
seed_text,
|
||
now,
|
||
&error,
|
||
)
|
||
.await
|
||
}
|
||
Err(error) => Err(puzzle_error_response(
|
||
request_context,
|
||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
map_puzzle_client_error(error),
|
||
)),
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
|
||
state: &AppState,
|
||
request_context: &RequestContext,
|
||
session_id: &str,
|
||
owner_user_id: &str,
|
||
seed_text: String,
|
||
now: i64,
|
||
original_error: &SpacetimeClientError,
|
||
) -> Result<String, Response> {
|
||
let current_session = state
|
||
.spacetime_client()
|
||
.get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string())
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_error_response(
|
||
request_context,
|
||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
map_puzzle_client_error(error),
|
||
)
|
||
})?;
|
||
if !current_session.seed_text.trim().is_empty() {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
session_id,
|
||
owner_user_id,
|
||
error = %original_error,
|
||
"拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译"
|
||
);
|
||
return Ok(session_id.to_string());
|
||
}
|
||
|
||
// 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。
|
||
let replacement_session_id = build_prefixed_uuid_id("puzzle-session-");
|
||
let replacement = state
|
||
.spacetime_client()
|
||
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
|
||
session_id: replacement_session_id.clone(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
seed_text: seed_text.clone(),
|
||
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||
welcome_message_text: build_puzzle_welcome_text(&seed_text),
|
||
created_at_micros: now,
|
||
})
|
||
.await
|
||
.map_err(|error| {
|
||
puzzle_error_response(
|
||
request_context,
|
||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
map_puzzle_client_error(error),
|
||
)
|
||
})?;
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
old_session_id = %session_id,
|
||
new_session_id = %replacement.session_id,
|
||
owner_user_id,
|
||
error = %original_error,
|
||
"拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session"
|
||
);
|
||
Ok(replacement.session_id)
|
||
}
|
||
|
||
pub(crate) fn select_puzzle_level_for_api(
|
||
draft: &PuzzleResultDraftRecord,
|
||
level_id: Option<&str>,
|
||
) -> Result<PuzzleDraftLevelRecord, AppError> {
|
||
let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty());
|
||
if let Some(target_id) = normalized_level_id {
|
||
return draft
|
||
.levels
|
||
.iter()
|
||
.find(|level| level.level_id == target_id)
|
||
.cloned()
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图关卡不存在:{target_id}"),
|
||
}))
|
||
});
|
||
}
|
||
let level = draft.levels.first().cloned();
|
||
level.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图草稿缺少可编辑关卡",
|
||
}))
|
||
})
|
||
}
|
||
|
||
pub(crate) fn parse_puzzle_level_records_from_module_json(
|
||
value: &str,
|
||
) -> Result<Vec<PuzzleDraftLevelRecord>, AppError> {
|
||
let levels: Vec<module_puzzle::PuzzleDraftLevel> =
|
||
serde_json::from_str(value).map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图关卡列表 JSON 非法:{error}"),
|
||
}))
|
||
})?;
|
||
Ok(levels
|
||
.into_iter()
|
||
.map(|level| PuzzleDraftLevelRecord {
|
||
level_id: level.level_id,
|
||
level_name: level.level_name,
|
||
picture_description: level.picture_description,
|
||
picture_reference: level.picture_reference,
|
||
ui_background_prompt: level.ui_background_prompt,
|
||
ui_background_image_src: level.ui_background_image_src,
|
||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||
background_music: level
|
||
.background_music
|
||
.map(map_puzzle_audio_asset_domain_record),
|
||
candidates: level
|
||
.candidates
|
||
.into_iter()
|
||
.map(|candidate| PuzzleGeneratedImageCandidateRecord {
|
||
candidate_id: candidate.candidate_id,
|
||
image_src: candidate.image_src,
|
||
asset_id: candidate.asset_id,
|
||
prompt: candidate.prompt,
|
||
actual_prompt: candidate.actual_prompt,
|
||
source_type: candidate.source_type,
|
||
selected: candidate.selected,
|
||
})
|
||
.collect(),
|
||
selected_candidate_id: level.selected_candidate_id,
|
||
cover_image_src: level.cover_image_src,
|
||
cover_asset_id: level.cover_asset_id,
|
||
generation_status: level.generation_status,
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
pub(crate) async fn get_puzzle_session_for_image_generation(
|
||
state: &AppState,
|
||
session_id: String,
|
||
owner_user_id: String,
|
||
payload: &ExecutePuzzleAgentActionRequest,
|
||
normalized_levels_json: Option<&str>,
|
||
now: i64,
|
||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||
match state
|
||
.spacetime_client()
|
||
.get_puzzle_agent_session(session_id.clone(), owner_user_id.clone())
|
||
.await
|
||
{
|
||
Ok(session) => Ok(session),
|
||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||
// 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。
|
||
let fallback_session = build_puzzle_session_snapshot_from_action_payload(
|
||
session_id.as_str(),
|
||
payload,
|
||
normalized_levels_json,
|
||
now,
|
||
)?;
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
session_id = %session_id,
|
||
owner_user_id = %owner_user_id,
|
||
error = %error,
|
||
"拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照"
|
||
);
|
||
Ok(fallback_session)
|
||
}
|
||
Err(error) => Err(map_puzzle_client_error(error)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
|
||
session_id: &str,
|
||
payload: &ExecutePuzzleAgentActionRequest,
|
||
normalized_levels_json: Option<&str>,
|
||
now: i64,
|
||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||
let levels_json = normalized_levels_json.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||
"provider": "spacetimedb",
|
||
"message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片",
|
||
}))
|
||
})?;
|
||
let levels = parse_puzzle_level_records_from_module_json(levels_json)?;
|
||
let first_level = levels.first().cloned().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图草稿缺少可编辑关卡",
|
||
}))
|
||
})?;
|
||
let work_title = payload
|
||
.work_title
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(first_level.level_name.as_str())
|
||
.to_string();
|
||
let work_description = payload
|
||
.work_description
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.unwrap_or_default()
|
||
.to_string();
|
||
let summary = payload
|
||
.summary
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(first_level.picture_description.as_str())
|
||
.to_string();
|
||
let theme_tags = payload.theme_tags.clone().unwrap_or_default();
|
||
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack());
|
||
let draft = PuzzleResultDraftRecord {
|
||
work_title,
|
||
work_description,
|
||
level_name: first_level.level_name.clone(),
|
||
summary,
|
||
theme_tags,
|
||
forbidden_directives: Vec::new(),
|
||
creator_intent: None,
|
||
anchor_pack: anchor_pack.clone(),
|
||
candidates: first_level.candidates.clone(),
|
||
selected_candidate_id: first_level.selected_candidate_id.clone(),
|
||
cover_image_src: first_level.cover_image_src.clone(),
|
||
cover_asset_id: first_level.cover_asset_id.clone(),
|
||
generation_status: first_level.generation_status.clone(),
|
||
levels,
|
||
form_draft: None,
|
||
};
|
||
|
||
Ok(PuzzleAgentSessionRecord {
|
||
session_id: session_id.to_string(),
|
||
seed_text: String::new(),
|
||
current_turn: 0,
|
||
progress_percent: 94,
|
||
stage: "ready_to_publish".to_string(),
|
||
anchor_pack,
|
||
draft: Some(draft),
|
||
messages: Vec::new(),
|
||
last_assistant_reply: None,
|
||
published_profile_id: None,
|
||
suggested_actions: Vec::new(),
|
||
result_preview: None,
|
||
updated_at: format_timestamp_micros(now),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn map_puzzle_domain_anchor_pack(
|
||
anchor_pack: module_puzzle::PuzzleAnchorPack,
|
||
) -> PuzzleAnchorPackRecord {
|
||
PuzzleAnchorPackRecord {
|
||
theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise),
|
||
visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject),
|
||
visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood),
|
||
composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks),
|
||
tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn map_puzzle_domain_anchor_item(
|
||
anchor: module_puzzle::PuzzleAnchorItem,
|
||
) -> PuzzleAnchorItemRecord {
|
||
PuzzleAnchorItemRecord {
|
||
key: anchor.key,
|
||
label: anchor.label,
|
||
value: anchor.value,
|
||
status: anchor.status.as_str().to_string(),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn serialize_puzzle_levels_response(
|
||
request_context: &RequestContext,
|
||
levels: &[PuzzleDraftLevelResponse],
|
||
) -> Result<String, Response> {
|
||
let payload = levels
|
||
.iter()
|
||
.map(|level| {
|
||
json!({
|
||
"level_id": level.level_id,
|
||
"level_name": level.level_name,
|
||
"picture_description": level.picture_description,
|
||
"picture_reference": level.picture_reference,
|
||
"ui_background_prompt": level.ui_background_prompt,
|
||
"ui_background_image_src": level.ui_background_image_src,
|
||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||
"candidates": level
|
||
.candidates
|
||
.iter()
|
||
.map(|candidate| {
|
||
json!({
|
||
"candidate_id": candidate.candidate_id,
|
||
"image_src": candidate.image_src,
|
||
"asset_id": candidate.asset_id,
|
||
"prompt": candidate.prompt,
|
||
"actual_prompt": candidate.actual_prompt,
|
||
"source_type": candidate.source_type,
|
||
"selected": candidate.selected,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
"selected_candidate_id": level.selected_candidate_id,
|
||
"cover_image_src": level.cover_image_src,
|
||
"cover_asset_id": level.cover_asset_id,
|
||
"generation_status": level.generation_status,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
serde_json::to_string(&payload).map_err(|error| {
|
||
puzzle_error_response(
|
||
request_context,
|
||
PUZZLE_WORKS_PROVIDER,
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_WORKS_PROVIDER,
|
||
"message": format!("拼图关卡列表序列化失败:{error}"),
|
||
})),
|
||
)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_levels_json_for_module(
|
||
value: Option<&str>,
|
||
) -> Result<Option<String>, String> {
|
||
let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else {
|
||
return Ok(None);
|
||
};
|
||
let levels: Vec<PuzzleDraftLevelResponse> =
|
||
serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?;
|
||
let payload = levels
|
||
.iter()
|
||
.map(|level| {
|
||
json!({
|
||
"level_id": level.level_id,
|
||
"level_name": level.level_name,
|
||
"picture_description": level.picture_description,
|
||
"picture_reference": level.picture_reference,
|
||
"ui_background_prompt": level.ui_background_prompt,
|
||
"ui_background_image_src": level.ui_background_image_src,
|
||
"ui_background_image_object_key": level.ui_background_image_object_key,
|
||
"background_music": puzzle_audio_asset_response_module_json(&level.background_music),
|
||
"candidates": level
|
||
.candidates
|
||
.iter()
|
||
.map(|candidate| {
|
||
json!({
|
||
"candidate_id": candidate.candidate_id,
|
||
"image_src": candidate.image_src,
|
||
"asset_id": candidate.asset_id,
|
||
"prompt": candidate.prompt,
|
||
"actual_prompt": candidate.actual_prompt,
|
||
"source_type": candidate.source_type,
|
||
"selected": candidate.selected,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
"selected_candidate_id": level.selected_candidate_id,
|
||
"cover_image_src": level.cover_image_src,
|
||
"cover_asset_id": level.cover_asset_id,
|
||
"generation_status": level.generation_status,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
serde_json::to_string(&payload)
|
||
.map(Some)
|
||
.map_err(|error| format!("拼图关卡列表序列化失败:{error}"))
|
||
}
|
||
|
||
pub(crate) fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||
let stable_suffix = session_id
|
||
.strip_prefix("puzzle-session-")
|
||
.unwrap_or(session_id);
|
||
(
|
||
format!("puzzle-work-{stable_suffix}"),
|
||
format!("puzzle-profile-{stable_suffix}"),
|
||
)
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub(crate) struct PuzzleLevelNaming {
|
||
pub(crate) level_name: String,
|
||
pub(crate) work_description: Option<String>,
|
||
pub(crate) work_tags: Vec<String>,
|
||
pub(crate) ui_background_prompt: Option<String>,
|
||
}
|
||
|
||
impl PuzzleLevelNaming {
|
||
fn fallback(picture_description: &str) -> Self {
|
||
Self {
|
||
level_name: build_fallback_puzzle_first_level_name(picture_description),
|
||
work_description: None,
|
||
work_tags: Vec::new(),
|
||
ui_background_prompt: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_first_level_name(
|
||
state: &AppState,
|
||
picture_description: &str,
|
||
) -> PuzzleLevelNaming {
|
||
if let Some(llm_client) = state.llm_client() {
|
||
let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description);
|
||
let response = llm_client
|
||
.request_text(
|
||
LlmTextRequest::new(vec![
|
||
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
|
||
LlmMessage::user(user_prompt),
|
||
])
|
||
.with_model(CREATION_TEMPLATE_LLM_MODEL)
|
||
.with_responses_api(),
|
||
)
|
||
.await;
|
||
match response {
|
||
Ok(response) => {
|
||
if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str())
|
||
{
|
||
return naming;
|
||
}
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
picture_chars = picture_description.chars().count(),
|
||
"拼图首关名模型返回非法,降级使用关键词名"
|
||
);
|
||
}
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
picture_chars = picture_description.chars().count(),
|
||
error = %error,
|
||
"拼图首关名生成失败,降级使用关键词名"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
PuzzleLevelNaming::fallback(picture_description)
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_first_level_name_from_image(
|
||
state: &AppState,
|
||
picture_description: &str,
|
||
image: &PuzzleDownloadedImage,
|
||
) -> Option<PuzzleLevelNaming> {
|
||
let Some(llm_client) = state.creative_agent_gpt5_client() else {
|
||
return None;
|
||
};
|
||
let Some(image_data_url) = build_puzzle_level_name_image_data_url(image) else {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
picture_chars = picture_description.chars().count(),
|
||
"拼图首关名图片输入压缩失败,保留文本关卡名"
|
||
);
|
||
return None;
|
||
};
|
||
let user_text = build_puzzle_first_level_name_vision_user_text(picture_description);
|
||
let response = llm_client
|
||
.request_text(
|
||
LlmTextRequest::new(vec![
|
||
LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT),
|
||
LlmMessage::user_multimodal(vec![
|
||
LlmMessageContentPart::InputText { text: user_text },
|
||
LlmMessageContentPart::InputImage {
|
||
image_url: image_data_url,
|
||
},
|
||
]),
|
||
])
|
||
.with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL)
|
||
.with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS),
|
||
)
|
||
.await;
|
||
|
||
match response {
|
||
Ok(response) => {
|
||
parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL,
|
||
picture_chars = picture_description.chars().count(),
|
||
"拼图首关名视觉模型返回非法,保留文本关卡名"
|
||
);
|
||
None
|
||
})
|
||
}
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL,
|
||
picture_chars = picture_description.chars().count(),
|
||
error = %error,
|
||
"拼图首关名视觉生成失败,保留文本关卡名"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_level_name_image_data_url(
|
||
image: &PuzzleDownloadedImage,
|
||
) -> Option<String> {
|
||
let bytes = resize_puzzle_level_name_image_bytes(image.bytes.as_slice())
|
||
.unwrap_or_else(|| image.bytes.clone());
|
||
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||
"image/png"
|
||
} else {
|
||
image.mime_type.as_str()
|
||
};
|
||
Some(format!(
|
||
"data:{};base64,{}",
|
||
normalize_puzzle_downloaded_image_mime_type(mime_type),
|
||
BASE64_STANDARD.encode(bytes)
|
||
))
|
||
}
|
||
|
||
pub(crate) fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||
let image = image::load_from_memory(bytes).ok()?;
|
||
let resized = image.resize(
|
||
PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE,
|
||
PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE,
|
||
image::imageops::FilterType::Triangle,
|
||
);
|
||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
||
Some(cursor.into_inner())
|
||
}
|
||
|
||
pub(crate) fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming> {
|
||
let trimmed = text.trim();
|
||
let json_text = if let Some(start) = trimmed.find('{')
|
||
&& let Some(end) = trimmed.rfind('}')
|
||
&& end > start
|
||
{
|
||
&trimmed[start..=end]
|
||
} else {
|
||
trimmed
|
||
};
|
||
let parsed = serde_json::from_str::<Value>(json_text).ok();
|
||
if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) {
|
||
return None;
|
||
}
|
||
let raw_name = parsed
|
||
.as_ref()
|
||
.and_then(|value| value.get("levelName").and_then(Value::as_str))
|
||
.or_else(|| {
|
||
parsed
|
||
.as_ref()
|
||
.and_then(|value| value.get("level_name").and_then(Value::as_str))
|
||
})
|
||
.unwrap_or(trimmed);
|
||
let level_name = normalize_puzzle_first_level_name(raw_name)?;
|
||
let work_description = parsed
|
||
.as_ref()
|
||
.and_then(parse_puzzle_generated_work_description_field);
|
||
let work_tags = parsed
|
||
.as_ref()
|
||
.and_then(parse_puzzle_generated_work_tags_field)
|
||
.unwrap_or_default();
|
||
let ui_background_prompt = parsed
|
||
.as_ref()
|
||
.and_then(parse_puzzle_ui_background_prompt_field);
|
||
|
||
Some(PuzzleLevelNaming {
|
||
level_name,
|
||
work_description,
|
||
work_tags,
|
||
ui_background_prompt,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn parse_puzzle_first_level_name_from_text(text: &str) -> Option<String> {
|
||
parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name)
|
||
}
|
||
|
||
pub(crate) fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
|
||
value
|
||
.get("uiBackgroundPrompt")
|
||
.and_then(Value::as_str)
|
||
.or_else(|| value.get("ui_background_prompt").and_then(Value::as_str))
|
||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||
}
|
||
|
||
pub(crate) fn parse_puzzle_generated_work_description_field(value: &Value) -> Option<String> {
|
||
value
|
||
.get("workDescription")
|
||
.and_then(Value::as_str)
|
||
.or_else(|| value.get("work_description").and_then(Value::as_str))
|
||
.and_then(normalize_puzzle_generated_work_description)
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_generated_work_description(value: &str) -> Option<String> {
|
||
let normalized = value
|
||
.trim()
|
||
.trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
})
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
let description = normalized.chars().take(80).collect::<String>();
|
||
(description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description))
|
||
.then_some(description)
|
||
}
|
||
|
||
pub(crate) fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option<Vec<String>> {
|
||
let tags_value = value
|
||
.get("workTags")
|
||
.or_else(|| value.get("work_tags"))
|
||
.or_else(|| value.get("themeTags"))
|
||
.or_else(|| value.get("theme_tags"))
|
||
.or_else(|| value.get("tags"))?;
|
||
let raw_tags = match tags_value {
|
||
Value::Array(items) => items
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(ToString::to_string)
|
||
.collect::<Vec<_>>(),
|
||
Value::String(text) => text
|
||
.split([',', ',', '、', '\n', '|', '/'])
|
||
.map(ToString::to_string)
|
||
.collect::<Vec<_>>(),
|
||
_ => Vec::new(),
|
||
};
|
||
let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags);
|
||
(tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags)
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_generated_work_tag_candidates<S>(
|
||
candidates: impl IntoIterator<Item = S>,
|
||
) -> Vec<String>
|
||
where
|
||
S: AsRef<str>,
|
||
{
|
||
let mut tags = Vec::new();
|
||
for candidate in candidates {
|
||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||
if normalized.is_empty()
|
||
|| looks_like_puzzle_json_field_name(&normalized)
|
||
|| tags.iter().any(|tag| tag == &normalized)
|
||
{
|
||
continue;
|
||
}
|
||
tags.push(normalized);
|
||
if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||
break;
|
||
}
|
||
}
|
||
tags
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
|
||
let normalized = value
|
||
.trim()
|
||
.trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
})
|
||
.split_whitespace()
|
||
.collect::<Vec<_>>()
|
||
.join("");
|
||
let filtered = normalized
|
||
.replace("拼图槽", "")
|
||
.replace("棋盘", "")
|
||
.replace("HUD", "")
|
||
.replace("按钮", "")
|
||
.replace("文字", "")
|
||
.replace("水印", "")
|
||
.replace("数字", "")
|
||
.replace("拼图碎片", "")
|
||
.replace("完整拼图图像", "")
|
||
.replace("教程浮层", "");
|
||
let prompt = filtered
|
||
.chars()
|
||
.take(160)
|
||
.collect::<String>()
|
||
.trim()
|
||
.trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':'))
|
||
.to_string();
|
||
if prompt.chars().count() >= 12 {
|
||
Some(prompt)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||
let normalized = value
|
||
.trim()
|
||
.trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
})
|
||
.trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')'))
|
||
.chars()
|
||
.filter(|ch| {
|
||
!matches!(
|
||
ch,
|
||
'#' | '"'
|
||
| '\''
|
||
| '`'
|
||
| ' '
|
||
| '\t'
|
||
| '\r'
|
||
| '\n'
|
||
| ','
|
||
| '。'
|
||
| '、'
|
||
| ';'
|
||
| ':'
|
||
| '!'
|
||
| '?'
|
||
| '“'
|
||
| '”'
|
||
| '《'
|
||
| '》'
|
||
)
|
||
})
|
||
.take(12)
|
||
.collect::<String>();
|
||
let normalized = strip_puzzle_level_name_generic_words(normalized);
|
||
if normalized.chars().count() >= 2
|
||
&& !matches!(
|
||
normalized.as_str(),
|
||
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||
)
|
||
&& !looks_like_puzzle_json_field_name(&normalized)
|
||
{
|
||
Some(normalized)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
pub(crate) fn looks_like_puzzle_json_field_name(value: &str) -> bool {
|
||
let normalized = value.trim().trim_matches(|ch: char| {
|
||
ch.is_ascii_punctuation()
|
||
|| matches!(
|
||
ch,
|
||
',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》'
|
||
)
|
||
});
|
||
let compact = normalized.to_ascii_lowercase().replace('_', "");
|
||
matches!(compact.as_str(), "levelnam" | "levelname")
|
||
|| [
|
||
"levelname",
|
||
"workdescription",
|
||
"worktags",
|
||
"themetags",
|
||
"uibackgroundprompt",
|
||
]
|
||
.iter()
|
||
.any(|field| {
|
||
compact == *field
|
||
|| (compact.len() >= 6 && field.starts_with(compact.as_str()))
|
||
|| compact.starts_with(field)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn looks_like_puzzle_json_fragment(value: &str) -> bool {
|
||
let trimmed = value.trim();
|
||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||
return true;
|
||
}
|
||
let lower = trimmed.to_ascii_lowercase();
|
||
[
|
||
"\"levelnam",
|
||
"\"levelname\"",
|
||
"\"level_name\"",
|
||
"\"workdescription\"",
|
||
"\"work_description\"",
|
||
"\"worktags\"",
|
||
"\"work_tags\"",
|
||
"\"uibackgroundprompt\"",
|
||
"\"ui_background_prompt\"",
|
||
]
|
||
.iter()
|
||
.any(|field| lower.contains(field))
|
||
}
|
||
|
||
pub(crate) fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||
for prefix in ["第一关", "关卡名", "关卡"] {
|
||
value = value.trim_start_matches(prefix).to_string();
|
||
}
|
||
for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] {
|
||
value = value.trim_end_matches(suffix).to_string();
|
||
}
|
||
value.chars().take(8).collect()
|
||
}
|
||
|
||
pub(crate) fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String {
|
||
let source = picture_description.trim();
|
||
if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) {
|
||
return "雨夜猫街".to_string();
|
||
}
|
||
if source.contains("猫") && source.contains('灯') {
|
||
return "暖灯猫街".to_string();
|
||
}
|
||
for (keyword, level_name) in [
|
||
("雨夜", "雨夜灯街"),
|
||
("猫", "暖灯猫街"),
|
||
("狗", "花园小狗"),
|
||
("神庙", "神庙遗光"),
|
||
("遗迹", "遗迹谜光"),
|
||
("森林", "森林秘境"),
|
||
("城市", "霓虹城市"),
|
||
("机械", "机械迷城"),
|
||
("蒸汽", "蒸汽街区"),
|
||
("海", "海岸微光"),
|
||
("花", "花园晨光"),
|
||
("雪", "雪境小径"),
|
||
("龙", "龙影高塔"),
|
||
("灯", "暖灯街角"),
|
||
("塔", "塔顶星光"),
|
||
] {
|
||
if source.contains(keyword) {
|
||
return level_name.to_string();
|
||
}
|
||
}
|
||
"奇境初见".to_string()
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_levels_with_primary_update(
|
||
draft: &PuzzleResultDraftRecord,
|
||
target_level: &PuzzleDraftLevelRecord,
|
||
picture_reference: Option<&str>,
|
||
) -> Vec<PuzzleDraftLevelRecord> {
|
||
let mut levels = draft.levels.clone();
|
||
if let Some(index) = levels
|
||
.iter()
|
||
.position(|level| level.level_id == target_level.level_id)
|
||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||
{
|
||
levels[index].level_name = target_level.level_name.clone();
|
||
levels[index].ui_background_prompt = target_level.ui_background_prompt.clone();
|
||
levels[index].ui_background_image_src = target_level.ui_background_image_src.clone();
|
||
levels[index].ui_background_image_object_key =
|
||
target_level.ui_background_image_object_key.clone();
|
||
if let Some(picture_reference) = picture_reference
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
levels[index].picture_reference = Some(picture_reference.to_string());
|
||
}
|
||
}
|
||
levels
|
||
}
|
||
|
||
pub(crate) fn attach_selected_puzzle_candidate_to_levels(
|
||
levels: &mut [PuzzleDraftLevelRecord],
|
||
target_level_id: &str,
|
||
candidate: &PuzzleGeneratedImageCandidateRecord,
|
||
) {
|
||
if let Some(index) = levels
|
||
.iter()
|
||
.position(|level| level.level_id == target_level_id)
|
||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||
{
|
||
let level = &mut levels[index];
|
||
level.candidates.clear();
|
||
let mut candidate = candidate.clone();
|
||
candidate.selected = true;
|
||
level.selected_candidate_id = Some(candidate.candidate_id.clone());
|
||
level.cover_image_src = Some(candidate.image_src.clone());
|
||
level.cover_asset_id = Some(candidate.asset_id.clone());
|
||
level.candidates.push(candidate);
|
||
level.generation_status = "ready".to_string();
|
||
}
|
||
}
|
||
|
||
pub(crate) fn resolve_puzzle_initial_ui_background_prompt(
|
||
draft: &PuzzleResultDraftRecord,
|
||
target_level: &PuzzleDraftLevelRecord,
|
||
) -> String {
|
||
target_level
|
||
.ui_background_prompt
|
||
.as_deref()
|
||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||
.unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level))
|
||
}
|
||
|
||
pub(crate) fn normalize_puzzle_ui_background_prompt(
|
||
raw_prompt: &str,
|
||
draft: &PuzzleResultDraftRecord,
|
||
target_level: &PuzzleDraftLevelRecord,
|
||
) -> String {
|
||
let prompt = raw_prompt.trim();
|
||
if !prompt.is_empty() {
|
||
return prompt.chars().take(420).collect();
|
||
}
|
||
|
||
let title = draft.work_title.trim();
|
||
let title = if title.is_empty() {
|
||
target_level.level_name.trim()
|
||
} else {
|
||
title
|
||
};
|
||
let tags = draft
|
||
.theme_tags
|
||
.iter()
|
||
.map(|tag| tag.trim())
|
||
.filter(|tag| !tag.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
[
|
||
title,
|
||
draft.work_description.trim(),
|
||
target_level.picture_description.trim(),
|
||
tags.as_str(),
|
||
PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER,
|
||
]
|
||
.into_iter()
|
||
.filter(|value| !value.is_empty())
|
||
.collect::<Vec<_>>()
|
||
.join("。")
|
||
.chars()
|
||
.take(420)
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn build_puzzle_ui_background_generation_prompt(
|
||
level_name: &str,
|
||
prompt: &str,
|
||
) -> String {
|
||
let level_name = level_name.trim();
|
||
let title_clause = if level_name.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("当前拼图关卡名称:{level_name}。")
|
||
};
|
||
format!(
|
||
"{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn attach_puzzle_level_ui_background(
|
||
levels: &mut [PuzzleDraftLevelRecord],
|
||
level_id: &str,
|
||
prompt: String,
|
||
generated: GeneratedPuzzleUiBackgroundResponse,
|
||
) {
|
||
let Some(index) = levels
|
||
.iter()
|
||
.position(|level| level.level_id == level_id)
|
||
.or_else(|| (!levels.is_empty()).then_some(0))
|
||
else {
|
||
return;
|
||
};
|
||
levels[index].ui_background_prompt = Some(prompt);
|
||
levels[index].ui_background_image_src = Some(generated.image_src);
|
||
levels[index].ui_background_image_object_key = Some(generated.object_key);
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_background_music_required(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
profile_id: &str,
|
||
title: &str,
|
||
) -> Result<CreationAudioAsset, AppError> {
|
||
let normalized_title = title.trim();
|
||
if normalized_title.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||
})),
|
||
);
|
||
}
|
||
generate_background_music_asset_for_creation(
|
||
state,
|
||
owner_user_id,
|
||
String::new(),
|
||
normalized_title.to_string(),
|
||
Some("轻快, 拼图, 循环, instrumental".to_string()),
|
||
None,
|
||
GeneratedCreationAudioTarget {
|
||
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
|
||
entity_id: profile_id.to_string(),
|
||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
|
||
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
|
||
profile_id: Some(profile_id.to_string()),
|
||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||
state: &AppState,
|
||
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,
|
||
owner_user_id,
|
||
session_id,
|
||
target_level.level_name.as_str(),
|
||
prompt.as_str(),
|
||
)
|
||
.await?;
|
||
Ok((prompt, generated))
|
||
}
|
||
|
||
pub(crate) fn ensure_puzzle_initial_level_assets_ready(
|
||
level: &PuzzleDraftLevelRecord,
|
||
) -> Result<(), AppError> {
|
||
let has_ui_background = level
|
||
.ui_background_image_src
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty())
|
||
|| level
|
||
.ui_background_image_object_key
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.is_some_and(|value| !value.is_empty());
|
||
if has_ui_background {
|
||
return Ok(());
|
||
}
|
||
|
||
let mut missing = Vec::new();
|
||
if !has_ui_background {
|
||
missing.push("UI背景图");
|
||
}
|
||
|
||
Err(
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")),
|
||
"missingAssets": missing,
|
||
})),
|
||
)
|
||
}
|
||
|
||
pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||
levels: &'a [PuzzleDraftLevelRecord],
|
||
level_id: &str,
|
||
) -> Option<&'a PuzzleDraftLevelRecord> {
|
||
levels
|
||
.iter()
|
||
.find(|level| level.level_id == level_id)
|
||
.or_else(|| levels.first())
|
||
}
|
||
|
||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||
state: &AppState,
|
||
session_id: String,
|
||
owner_user_id: String,
|
||
prompt_text: Option<&str>,
|
||
reference_image_src: Option<&str>,
|
||
image_model: Option<&str>,
|
||
now: i64,
|
||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||
let compiled_session = state
|
||
.spacetime_client()
|
||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||
.await
|
||
.map_err(map_puzzle_compile_error)?;
|
||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图结果页草稿尚未生成",
|
||
}))
|
||
})?;
|
||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||
let fallback_level_name = target_level.level_name.clone();
|
||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||
prompt_text,
|
||
&target_level.picture_description,
|
||
&draft.summary,
|
||
);
|
||
let generated_naming =
|
||
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
|
||
target_level.level_name = generated_naming.level_name.clone();
|
||
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
|
||
let mut generated_metadata = generated_naming;
|
||
// 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。
|
||
let candidates_future = generate_puzzle_image_candidates(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
&compiled_session.session_id,
|
||
&target_level.level_name,
|
||
&image_prompt,
|
||
reference_image_src,
|
||
true,
|
||
image_model,
|
||
1,
|
||
target_level.candidates.len(),
|
||
);
|
||
let ui_background_future = generate_puzzle_initial_ui_background_required(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
compiled_session.session_id.as_str(),
|
||
&draft,
|
||
&target_level,
|
||
);
|
||
// 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。
|
||
let (candidates_result, ui_background_result) =
|
||
tokio::join!(candidates_future, ui_background_future);
|
||
let mut candidates = candidates_result?;
|
||
if let Some(first_candidate) = candidates.first()
|
||
&& let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
|
||
state,
|
||
target_level.picture_description.as_str(),
|
||
&first_candidate.downloaded_image,
|
||
)
|
||
.await
|
||
{
|
||
target_level.level_name = refined_naming.level_name;
|
||
if refined_naming.work_description.is_some() {
|
||
generated_metadata.work_description = refined_naming.work_description;
|
||
}
|
||
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||
generated_metadata.work_tags = refined_naming.work_tags;
|
||
}
|
||
generated_metadata.level_name = target_level.level_name.clone();
|
||
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
|
||
}
|
||
let generated_level_name = target_level.level_name.clone();
|
||
let mut updated_levels =
|
||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||
for candidate in &mut candidates {
|
||
candidate.record.prompt = image_prompt.clone();
|
||
}
|
||
let selected_candidate_id = candidates
|
||
.iter()
|
||
.find(|candidate| candidate.record.selected)
|
||
.or_else(|| candidates.first())
|
||
.map(|candidate| candidate.record.candidate_id.clone())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图候选图生成结果为空",
|
||
}))
|
||
})?;
|
||
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
|
||
let (ui_prompt, ui_background) = ui_background_result?;
|
||
attach_puzzle_level_ui_background(
|
||
&mut updated_levels,
|
||
target_level.level_id.as_str(),
|
||
ui_prompt,
|
||
ui_background,
|
||
);
|
||
if let Some(selected_candidate) = candidates
|
||
.iter()
|
||
.find(|candidate| candidate.record.selected)
|
||
.or_else(|| candidates.first())
|
||
{
|
||
attach_selected_puzzle_candidate_to_levels(
|
||
&mut updated_levels,
|
||
target_level.level_id.as_str(),
|
||
&selected_candidate.record,
|
||
);
|
||
}
|
||
let ready_level =
|
||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图草稿资源生成完成后未找到目标关卡",
|
||
}))
|
||
})?;
|
||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||
let levels_json_with_generated_name =
|
||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||
let work_title = if draft.work_title.trim().is_empty()
|
||
|| draft.work_title.trim() == fallback_level_name.trim()
|
||
{
|
||
generated_level_name.clone()
|
||
} else {
|
||
draft.work_title.clone()
|
||
};
|
||
let work_description = if draft.work_description.trim().is_empty() {
|
||
generated_metadata
|
||
.work_description
|
||
.clone()
|
||
.unwrap_or_else(|| draft.work_description.clone())
|
||
} else {
|
||
draft.work_description.clone()
|
||
};
|
||
let theme_tags = if draft.theme_tags.is_empty()
|
||
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||
{
|
||
generated_metadata.work_tags.clone()
|
||
} else {
|
||
draft.theme_tags.clone()
|
||
};
|
||
let candidates_json = serde_json::to_string(
|
||
&candidates
|
||
.iter()
|
||
.map(|candidate| to_puzzle_generated_image_candidate(&candidate.record))
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图候选图序列化失败:{error}"),
|
||
}))
|
||
})?;
|
||
let (saved_session, save_used_fallback) = state
|
||
.spacetime_client()
|
||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||
session_id: compiled_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.clone(),
|
||
candidates_json,
|
||
saved_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.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)
|
||
}
|
||
})?;
|
||
let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
|
||
match state
|
||
.spacetime_client()
|
||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||
profile_id,
|
||
owner_user_id: owner_user_id.clone(),
|
||
work_title,
|
||
work_description: work_description.clone(),
|
||
level_name: generated_level_name.clone(),
|
||
summary: work_description,
|
||
theme_tags,
|
||
cover_image_src: ready_level.cover_image_src.clone(),
|
||
cover_asset_id: ready_level.cover_asset_id.clone(),
|
||
levels_json: levels_json_with_generated_name.clone(),
|
||
updated_at_micros: now,
|
||
})
|
||
.await
|
||
.map_err(map_puzzle_client_error)
|
||
{
|
||
Ok(_) => {}
|
||
Err(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(),
|
||
"拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照"
|
||
);
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||
saved_session,
|
||
&generated_metadata,
|
||
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,
|
||
owner_user_id,
|
||
level_id: Some(target_level.level_id),
|
||
candidate_id: selected_candidate_id,
|
||
selected_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
{
|
||
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||
session,
|
||
&generated_metadata,
|
||
fallback_level_name.as_str(),
|
||
now,
|
||
)),
|
||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
session_id = %saved_session.session_id,
|
||
error = %error,
|
||
"拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照"
|
||
);
|
||
Ok(saved_session)
|
||
}
|
||
Err(error) => Err(map_puzzle_client_error(error)),
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||
state: &AppState,
|
||
session_id: String,
|
||
owner_user_id: String,
|
||
prompt_text: Option<&str>,
|
||
reference_image_src: Option<&str>,
|
||
now: i64,
|
||
) -> Result<PuzzleAgentSessionRecord, AppError> {
|
||
let uploaded_image_src = reference_image_src
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"field": "referenceImageSrc",
|
||
"message": "关闭 AI 重绘时必须上传拼图图片。",
|
||
}))
|
||
})?;
|
||
let http_client = reqwest::Client::new();
|
||
let uploaded_downloaded_image =
|
||
resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src)
|
||
.await
|
||
.map(PuzzleDownloadedImage::from_resolved_reference_image)
|
||
.map_err(|error| {
|
||
if error.status_code() == StatusCode::BAD_REQUEST {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"field": "referenceImageSrc",
|
||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
|
||
}))
|
||
} else {
|
||
error
|
||
}
|
||
})?;
|
||
let compiled_session = state
|
||
.spacetime_client()
|
||
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
|
||
.await
|
||
.map_err(map_puzzle_compile_error)?;
|
||
let draft = compiled_session.draft.clone().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图结果页草稿尚未生成",
|
||
}))
|
||
})?;
|
||
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
|
||
let fallback_level_name = target_level.level_name.clone();
|
||
let image_prompt = resolve_puzzle_draft_cover_prompt(
|
||
prompt_text,
|
||
&target_level.picture_description,
|
||
&draft.summary,
|
||
);
|
||
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。
|
||
let candidate_id = format!(
|
||
"{}-candidate-{}",
|
||
compiled_session.session_id,
|
||
target_level.candidates.len() + 1
|
||
);
|
||
let level_name_future =
|
||
generate_puzzle_first_level_name(state, &target_level.picture_description);
|
||
let image_level_name_future = generate_puzzle_first_level_name_from_image(
|
||
state,
|
||
target_level.picture_description.as_str(),
|
||
&uploaded_downloaded_image,
|
||
);
|
||
let (mut generated_naming, refined_naming) =
|
||
tokio::join!(level_name_future, image_level_name_future);
|
||
if let Some(refined_naming) = refined_naming {
|
||
generated_naming.level_name = refined_naming.level_name;
|
||
if refined_naming.ui_background_prompt.is_some() {
|
||
generated_naming.ui_background_prompt = refined_naming.ui_background_prompt;
|
||
}
|
||
if refined_naming.work_description.is_some() {
|
||
generated_naming.work_description = refined_naming.work_description;
|
||
}
|
||
if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT {
|
||
generated_naming.work_tags = refined_naming.work_tags;
|
||
}
|
||
}
|
||
target_level.level_name = generated_naming.level_name.clone();
|
||
target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone();
|
||
let mut generated_metadata = generated_naming;
|
||
generated_metadata.level_name = target_level.level_name.clone();
|
||
generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone();
|
||
let generated_level_name = target_level.level_name.clone();
|
||
let mut updated_levels =
|
||
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
|
||
let persist_upload_future = persist_puzzle_generated_asset(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
&compiled_session.session_id,
|
||
target_level.level_name.as_str(),
|
||
candidate_id.as_str(),
|
||
"uploaded-direct",
|
||
uploaded_downloaded_image.clone(),
|
||
current_utc_micros(),
|
||
);
|
||
let ui_background_future = generate_puzzle_initial_ui_background_required(
|
||
state,
|
||
owner_user_id.as_str(),
|
||
compiled_session.session_id.as_str(),
|
||
&draft,
|
||
&target_level,
|
||
);
|
||
// 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。
|
||
let (persisted_upload_result, ui_background_result) =
|
||
tokio::join!(persist_upload_future, ui_background_future);
|
||
let persisted_upload = persisted_upload_result?;
|
||
let (ui_prompt, ui_background) = ui_background_result?;
|
||
attach_puzzle_level_ui_background(
|
||
&mut updated_levels,
|
||
target_level.level_id.as_str(),
|
||
ui_prompt,
|
||
ui_background,
|
||
);
|
||
attach_selected_puzzle_candidate_to_levels(
|
||
&mut updated_levels,
|
||
target_level.level_id.as_str(),
|
||
&PuzzleGeneratedImageCandidateRecord {
|
||
candidate_id: candidate_id.clone(),
|
||
image_src: persisted_upload.image_src.clone(),
|
||
asset_id: persisted_upload.asset_id.clone(),
|
||
prompt: image_prompt.clone(),
|
||
actual_prompt: None,
|
||
source_type: "uploaded".to_string(),
|
||
selected: true,
|
||
},
|
||
);
|
||
let ready_level =
|
||
find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str())
|
||
.ok_or_else(|| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": "拼图草稿资源生成完成后未找到目标关卡",
|
||
}))
|
||
})?;
|
||
ensure_puzzle_initial_level_assets_ready(ready_level)?;
|
||
let levels_json_with_generated_name =
|
||
Some(serialize_puzzle_level_records_for_module(&updated_levels)?);
|
||
let work_title = if draft.work_title.trim().is_empty()
|
||
|| draft.work_title.trim() == fallback_level_name.trim()
|
||
{
|
||
generated_level_name.clone()
|
||
} else {
|
||
draft.work_title.clone()
|
||
};
|
||
let work_description = if draft.work_description.trim().is_empty() {
|
||
generated_metadata
|
||
.work_description
|
||
.clone()
|
||
.unwrap_or_else(|| draft.work_description.clone())
|
||
} else {
|
||
draft.work_description.clone()
|
||
};
|
||
let theme_tags = if draft.theme_tags.is_empty()
|
||
&& generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||
{
|
||
generated_metadata.work_tags.clone()
|
||
} else {
|
||
draft.theme_tags.clone()
|
||
};
|
||
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||
candidate_id: candidate_id.clone(),
|
||
image_src: persisted_upload.image_src,
|
||
asset_id: persisted_upload.asset_id,
|
||
prompt: image_prompt,
|
||
actual_prompt: None,
|
||
source_type: "uploaded".to_string(),
|
||
selected: true,
|
||
};
|
||
let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate(
|
||
&candidate,
|
||
)])
|
||
.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
"message": format!("拼图上传图候选序列化失败:{error}"),
|
||
}))
|
||
})?;
|
||
let (saved_session, save_used_fallback) = state
|
||
.spacetime_client()
|
||
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
|
||
session_id: compiled_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.clone(),
|
||
candidates_json,
|
||
saved_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.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()
|
||
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||
profile_id,
|
||
owner_user_id: owner_user_id.clone(),
|
||
work_title,
|
||
work_description: work_description.clone(),
|
||
level_name: generated_level_name.clone(),
|
||
summary: work_description,
|
||
theme_tags,
|
||
cover_image_src: ready_level.cover_image_src.clone(),
|
||
cover_asset_id: ready_level.cover_asset_id.clone(),
|
||
levels_json: levels_json_with_generated_name.clone(),
|
||
updated_at_micros: now,
|
||
})
|
||
.await
|
||
.map_err(map_puzzle_client_error)
|
||
{
|
||
Ok(_) => {}
|
||
Err(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(),
|
||
"拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照"
|
||
);
|
||
}
|
||
Err(error) => return Err(error),
|
||
}
|
||
let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||
saved_session,
|
||
&generated_metadata,
|
||
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,
|
||
owner_user_id,
|
||
level_id: Some(target_level.level_id),
|
||
candidate_id,
|
||
selected_at_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
{
|
||
Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||
session,
|
||
&generated_metadata,
|
||
fallback_level_name.as_str(),
|
||
now,
|
||
)),
|
||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||
tracing::warn!(
|
||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||
session_id = %saved_session.session_id,
|
||
error = %error,
|
||
"拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照"
|
||
);
|
||
Ok(saved_session)
|
||
}
|
||
Err(error) => Err(map_puzzle_client_error(error)),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
|
||
mut session: PuzzleAgentSessionRecord,
|
||
target_level_id: &str,
|
||
candidates: Vec<PuzzleGeneratedImageCandidateRecord>,
|
||
picture_reference: Option<&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;
|
||
};
|
||
let mut candidates = candidates
|
||
.into_iter()
|
||
.take(1)
|
||
.map(|mut candidate| {
|
||
candidate.selected = true;
|
||
candidate
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let Some(selected) = candidates.first().cloned() else {
|
||
return session;
|
||
};
|
||
let level = &mut draft.levels[target_index];
|
||
level.candidates.clear();
|
||
level.candidates.append(&mut candidates);
|
||
level.selected_candidate_id = Some(selected.candidate_id.clone());
|
||
level.cover_image_src = Some(selected.image_src.clone());
|
||
level.cover_asset_id = Some(selected.asset_id.clone());
|
||
if let Some(picture_reference) = picture_reference
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
{
|
||
level.picture_reference = Some(picture_reference.to_string());
|
||
}
|
||
level.generation_status = "ready".to_string();
|
||
if target_index == 0 {
|
||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||
}
|
||
session.progress_percent = session.progress_percent.max(94);
|
||
session.stage = "ready_to_publish".to_string();
|
||
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
|
||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||
session
|
||
}
|
||
|
||
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,
|
||
level_name: &str,
|
||
previous_level_name: &str,
|
||
updated_at_micros: i64,
|
||
) -> PuzzleAgentSessionRecord {
|
||
let Some(draft) = session.draft.as_mut() else {
|
||
return session;
|
||
};
|
||
let normalized_name = level_name.trim();
|
||
if normalized_name.is_empty() {
|
||
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 = normalized_name.to_string();
|
||
let should_default_work_title =
|
||
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||
if target_index == 0 && should_default_work_title {
|
||
draft.work_title = normalized_name.to_string();
|
||
}
|
||
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_session_snapshot(
|
||
mut session: PuzzleAgentSessionRecord,
|
||
metadata: &PuzzleLevelNaming,
|
||
previous_level_name: &str,
|
||
updated_at_micros: i64,
|
||
) -> PuzzleAgentSessionRecord {
|
||
let Some(draft) = session.draft.as_mut() else {
|
||
return session;
|
||
};
|
||
apply_generated_puzzle_initial_metadata_to_draft(
|
||
draft,
|
||
metadata,
|
||
previous_level_name,
|
||
updated_at_micros,
|
||
);
|
||
session.updated_at = format_timestamp_micros(updated_at_micros);
|
||
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,
|
||
previous_level_name: &str,
|
||
_updated_at_micros: i64,
|
||
) {
|
||
let should_default_work_title =
|
||
draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim();
|
||
if should_default_work_title {
|
||
draft.work_title = metadata.level_name.clone();
|
||
}
|
||
|
||
if draft.work_description.trim().is_empty()
|
||
&& let Some(description) = metadata.work_description.as_ref()
|
||
{
|
||
draft.work_description = description.clone();
|
||
draft.summary = description.clone();
|
||
}
|
||
|
||
if draft.theme_tags.is_empty()
|
||
&& metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT
|
||
{
|
||
draft.theme_tags = metadata.work_tags.clone();
|
||
}
|
||
|
||
sync_puzzle_primary_draft_fields_from_level(draft);
|
||
}
|
||
|
||
pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||
let Some(primary_level) = draft.levels.first() else {
|
||
return;
|
||
};
|
||
draft.level_name = primary_level.level_name.clone();
|
||
draft.candidates = primary_level.candidates.clone();
|
||
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
|
||
draft.cover_image_src = primary_level.cover_image_src.clone();
|
||
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
||
draft.generation_status = primary_level.generation_status.clone();
|
||
draft.summary = draft.work_description.clone();
|
||
if draft.form_draft.is_some() {
|
||
draft.form_draft = Some(PuzzleFormDraftRecord {
|
||
work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()),
|
||
work_description: (!draft.work_description.trim().is_empty())
|
||
.then_some(draft.work_description.clone()),
|
||
picture_description: (!primary_level.picture_description.trim().is_empty())
|
||
.then_some(primary_level.picture_description.clone()),
|
||
});
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|