Files
Genarrative/server-rs/crates/api-server/src/puzzle/draft.rs
高物 7b37271f17 Puzzle: support history images & partial generation
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.
2026-05-19 10:02:13 +08:00

1944 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}