Files
Genarrative/server-rs/crates/api-server/src/puzzle/draft.rs
2026-05-28 14:50:13 +08:00

1996 lines
73 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: &PuzzleApiState,
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: &PuzzleApiState,
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,
level_scene_image_src: level.level_scene_image_src,
level_scene_image_object_key: level.level_scene_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_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: &PuzzleApiState,
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,
"level_scene_image_src": level.level_scene_image_src,
"level_scene_image_object_key": level.level_scene_image_object_key,
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
"level_background_image_src": level.level_background_image_src,
"level_background_image_object_key": level.level_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,
"level_scene_image_src": level.level_scene_image_src,
"level_scene_image_object_key": level.level_scene_image_object_key,
"ui_spritesheet_image_src": level.ui_spritesheet_image_src,
"ui_spritesheet_image_object_key": level.ui_spritesheet_image_object_key,
"level_background_image_src": level.level_background_image_src,
"level_background_image_object_key": level.level_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: &PuzzleApiState,
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: &PuzzleApiState,
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();
levels[index].level_scene_image_src = target_level.level_scene_image_src.clone();
levels[index].level_scene_image_object_key =
target_level.level_scene_image_object_key.clone();
levels[index].ui_spritesheet_image_src = target_level.ui_spritesheet_image_src.clone();
levels[index].ui_spritesheet_image_object_key =
target_level.ui_spritesheet_image_object_key.clone();
levels[index].level_background_image_src = target_level.level_background_image_src.clone();
levels[index].level_background_image_object_key =
target_level.level_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) fn attach_puzzle_level_asset_bundle(
levels: &mut [PuzzleDraftLevelRecord],
level_id: &str,
generated: GeneratedPuzzleLevelAssetBundle,
) {
let Some(index) = levels
.iter()
.position(|level| level.level_id == level_id)
.or_else(|| (!levels.is_empty()).then_some(0))
else {
return;
};
let level = &mut levels[index];
level.level_scene_image_src = Some(generated.level_scene.image_src);
level.level_scene_image_object_key = Some(generated.level_scene.object_key);
level.ui_spritesheet_image_src = Some(generated.ui_spritesheet.image_src);
level.ui_spritesheet_image_object_key = Some(generated.ui_spritesheet.object_key);
level.level_background_image_src = Some(generated.level_background.image_src.clone());
level.level_background_image_object_key = Some(generated.level_background.object_key.clone());
level.ui_background_image_src = Some(generated.level_background.image_src);
level.ui_background_image_object_key = Some(generated.level_background.object_key);
}
pub(crate) async fn generate_puzzle_initial_ui_background_required(
state: &PuzzleApiState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
draft: &PuzzleResultDraftRecord,
target_level: &PuzzleDraftLevelRecord,
) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> {
let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level);
let generated = generate_puzzle_ui_background_image(
state,
request_context,
owner_user_id,
session_id,
target_level.level_name.as_str(),
prompt.as_str(),
)
.await?;
Ok((prompt, generated))
}
pub(crate) async fn generate_puzzle_level_asset_bundle_required(
state: &PuzzleApiState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: &str,
target_level: &PuzzleDraftLevelRecord,
puzzle_image: &PuzzleDownloadedImage,
) -> Result<GeneratedPuzzleLevelAssetBundle, AppError> {
generate_puzzle_level_asset_bundle(
state,
request_context,
owner_user_id,
session_id,
target_level.level_name.as_str(),
puzzle_image,
)
.await
}
pub(crate) fn ensure_puzzle_initial_level_assets_ready(
level: &PuzzleDraftLevelRecord,
) -> Result<(), AppError> {
let has_level_background = level
.level_background_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| level
.level_background_image_object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let has_ui_spritesheet = level
.ui_spritesheet_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| level
.ui_spritesheet_image_object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if has_level_background && has_ui_spritesheet {
return Ok(());
}
let mut missing = Vec::new();
if !has_level_background {
missing.push("关卡背景图");
}
if !has_ui_spritesheet {
missing.push("UI spritesheet");
}
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: &PuzzleApiState,
request_context: &RequestContext,
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 (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id);
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;
// 点击生成草稿时一次性完成拼图主图和运行态资产包,前端只展示进度,不再承担业务编排。
let mut candidates = generate_puzzle_image_candidates(
state,
owner_user_id.as_str(),
Some(profile_id.as_str()),
&compiled_session.session_id,
&target_level.level_name,
&image_prompt,
reference_image_src,
true,
image_model,
1,
target_level.candidates.len(),
)
.await?;
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 spritesheet。
if let Some(selected_candidate) = candidates
.iter()
.find(|candidate| candidate.record.selected)
.or_else(|| candidates.first())
{
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
request_context,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&target_level,
&selected_candidate.downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
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)
}
})?;
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: &PuzzleApiState,
request_context: &RequestContext,
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(
state,
&http_client,
uploaded_image_src,
Some(owner_user_id.as_str()),
)
.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 重绘时上传图必须是拼图图片 assetObjectId、图片 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 persisted_upload = 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(),
)
.await?;
let asset_bundle = generate_puzzle_level_asset_bundle_required(
state,
request_context,
owner_user_id.as_str(),
compiled_session.session_id.as_str(),
&target_level,
&uploaded_downloaded_image,
)
.await?;
attach_puzzle_level_asset_bundle(
&mut updated_levels,
target_level.level_id.as_str(),
asset_bundle,
);
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
}