Files
Genarrative/server-rs/crates/api-server/src/puzzle/draft.rs
2026-05-18 17:50:16 +08:00

1910 lines
69 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 image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:首图 prompt 只依赖画面描述关卡名分支可以和生图分支并行OSS 临时路径使用已有名或确定性兜底名。
let level_name_future =
generate_puzzle_first_level_name(state, &target_level.picture_description);
// 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。
let candidates_future = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&image_level_name,
&image_prompt,
reference_image_src,
true,
image_model,
1,
target_level.candidates.len(),
);
let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future);
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;
let candidates = candidates_result?;
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": "拼图候选图生成结果为空",
}))
})?;
if let Some(refined_naming) = generate_puzzle_first_level_name_from_image(
state,
target_level.picture_description.as_str(),
&candidates[0].downloaded_image,
)
.await
{
target_level.level_name = refined_naming.level_name;
if refined_naming.ui_background_prompt.is_some() {
target_level.ui_background_prompt = refined_naming.ui_background_prompt;
}
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);
// 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await?;
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 uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时上传图必须是图片 Data URL。",
}))
})?;
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 image_level_name = if target_level.level_name.trim().is_empty() {
build_fallback_puzzle_first_level_name(&target_level.picture_description)
} else {
target_level.level_name.clone()
};
// 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
compiled_session.session_id,
target_level.candidates.len() + 1
);
let uploaded_downloaded_image = PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(),
mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()),
bytes: uploaded_image.bytes,
};
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 persist_upload_future = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
image_level_name.as_str(),
candidate_id.as_str(),
"uploaded-direct",
uploaded_downloaded_image.clone(),
current_utc_micros(),
);
let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!(
level_name_future,
image_level_name_future,
persist_upload_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 persisted_upload = persisted_upload_result?;
let mut updated_levels =
build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src);
// 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。
let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required(
state,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&draft,
&target_level,
)
.await?;
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_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
}