Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
@@ -122,7 +122,9 @@ const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
||||
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
||||
const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2";
|
||||
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
||||
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
||||
@@ -718,16 +720,20 @@ pub async fn execute_puzzle_agent_action(
|
||||
.as_deref()
|
||||
.map(|value| value.chars().count())
|
||||
.unwrap_or(0),
|
||||
has_reference_image = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false),
|
||||
has_reference_image = has_puzzle_reference_images(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
),
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
|
||||
"compile_puzzle_draft" => {
|
||||
let ai_redraw = payload.ai_redraw.unwrap_or(true);
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
|
||||
let prompt_text = payload
|
||||
.picture_description
|
||||
.as_deref()
|
||||
@@ -760,7 +766,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
payload.image_model.as_deref(),
|
||||
now,
|
||||
)
|
||||
@@ -891,6 +897,12 @@ pub async fn execute_puzzle_agent_action(
|
||||
payload.prompt_text.as_deref(),
|
||||
&target_level.picture_description,
|
||||
);
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src =
|
||||
reference_image_sources.first().map(String::as_str);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = target_level.candidates.len();
|
||||
@@ -900,7 +912,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&session.session_id,
|
||||
&target_level.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
payload.ai_redraw.unwrap_or(true),
|
||||
payload.image_model.as_deref(),
|
||||
candidate_count,
|
||||
@@ -934,7 +946,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&build_puzzle_levels_with_primary_update(
|
||||
&draft,
|
||||
&target_level,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
),
|
||||
)?);
|
||||
let candidates_json = serde_json::to_string(
|
||||
@@ -985,7 +997,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
),
|
||||
target_level.level_id.as_str(),
|
||||
candidates.into_records(),
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
))
|
||||
}
|
||||
@@ -3067,6 +3079,8 @@ fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PuzzleLevelNaming {
|
||||
level_name: String,
|
||||
work_description: Option<String>,
|
||||
work_tags: Vec<String>,
|
||||
ui_background_prompt: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3074,6 +3088,8 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -3150,7 +3166,7 @@ async fn generate_puzzle_first_level_name_from_image(
|
||||
]),
|
||||
])
|
||||
.with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL)
|
||||
.with_max_tokens(80),
|
||||
.with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3217,6 +3233,9 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
|
||||
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))
|
||||
@@ -3227,12 +3246,21 @@ fn parse_puzzle_level_naming_from_text(text: &str) -> Option<PuzzleLevelNaming>
|
||||
})
|
||||
.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,
|
||||
})
|
||||
}
|
||||
@@ -3250,6 +3278,55 @@ fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option<String> {
|
||||
.and_then(normalize_puzzle_generated_ui_background_prompt)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option<String> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
@@ -3331,6 +3408,7 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
normalized.as_str(),
|
||||
"第一关" | "画面" | "拼图" | "作品" | "关卡"
|
||||
)
|
||||
&& !looks_like_puzzle_json_field_name(&normalized)
|
||||
{
|
||||
Some(normalized)
|
||||
} else {
|
||||
@@ -3338,6 +3416,52 @@ fn normalize_puzzle_first_level_name(value: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn strip_puzzle_level_name_generic_words(mut value: String) -> String {
|
||||
for prefix in ["第一关", "关卡名", "关卡"] {
|
||||
value = value.trim_start_matches(prefix).to_string();
|
||||
@@ -3406,6 +3530,28 @@ fn build_puzzle_levels_with_primary_update(
|
||||
levels
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_puzzle_initial_ui_background_prompt(
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
target_level: &PuzzleDraftLevelRecord,
|
||||
@@ -3596,7 +3742,8 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
);
|
||||
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;
|
||||
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()
|
||||
@@ -3620,6 +3767,14 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
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 =
|
||||
@@ -3639,6 +3794,17 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
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(|| {
|
||||
@@ -3650,6 +3816,28 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
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()
|
||||
@@ -3707,6 +3895,43 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
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);
|
||||
}
|
||||
@@ -3721,7 +3946,12 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(session),
|
||||
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,
|
||||
@@ -3821,9 +4051,18 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
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;
|
||||
target_level.ui_background_prompt = generated_naming.ui_background_prompt;
|
||||
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 =
|
||||
@@ -3843,6 +4082,19 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
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(|| {
|
||||
@@ -3854,6 +4106,28 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
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,
|
||||
@@ -3916,6 +4190,43 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
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);
|
||||
}
|
||||
@@ -3930,7 +4241,12 @@ async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(session) => Ok(session),
|
||||
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,
|
||||
@@ -4046,6 +4362,53 @@ fn apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||
session
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) {
|
||||
let Some(primary_level) = draft.levels.first() else {
|
||||
return;
|
||||
@@ -4056,6 +4419,16 @@ fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftReco
|
||||
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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_puzzle_session_draft_snapshot(
|
||||
@@ -4170,6 +4543,9 @@ where
|
||||
{
|
||||
let mut tags = Vec::new();
|
||||
for candidate in candidates {
|
||||
if looks_like_puzzle_json_field_name(candidate.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let normalized = normalize_puzzle_tag(candidate.as_ref());
|
||||
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
|
||||
continue;
|
||||
@@ -4190,6 +4566,29 @@ where
|
||||
tags
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn normalize_puzzle_tag(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
@@ -4931,6 +5330,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
Some("data:image/png;base64,a"),
|
||||
&[
|
||||
"data:image/png;base64,a".to_string(),
|
||||
"data:image/png;base64,b".to_string(),
|
||||
"data:image/png;base64,c".to_string(),
|
||||
"data:image/png;base64,d".to_string(),
|
||||
"data:image/png;base64,e".to_string(),
|
||||
"data:image/png;base64,f".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(sources.len(), 5);
|
||||
assert_eq!(sources[0], "data:image/png;base64,a");
|
||||
assert_eq!(sources[1], "data:image/png;base64,b");
|
||||
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
@@ -5008,6 +5427,7 @@ mod tests {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -5055,16 +5475,28 @@ mod tests {
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||
Some("雨夜猫街".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_naming_parser_accepts_ui_background_prompt() {
|
||||
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
|
||||
let naming = parse_puzzle_level_naming_from_text(
|
||||
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||
)
|
||||
.expect("naming should parse");
|
||||
|
||||
assert_eq!(naming.level_name, "雨夜猫街");
|
||||
assert_eq!(
|
||||
naming.work_description.as_deref(),
|
||||
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
|
||||
);
|
||||
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
|
||||
assert!(naming.work_tags.contains(&"雨夜".to_string()));
|
||||
assert!(naming.work_tags.contains(&"猫咪".to_string()));
|
||||
assert!(naming.work_tags.contains(&"灯牌".to_string()));
|
||||
assert_eq!(
|
||||
naming.ui_background_prompt.as_deref(),
|
||||
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||
@@ -5139,6 +5571,7 @@ mod tests {
|
||||
action: "generate_puzzle_images".to_string(),
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -5173,6 +5606,61 @@ mod tests {
|
||||
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
|
||||
let mut session = PuzzleAgentSessionRecord {
|
||||
session_id: "puzzle-session-1".to_string(),
|
||||
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||
current_turn: 1,
|
||||
progress_percent: 94,
|
||||
stage: "ready_to_publish".to_string(),
|
||||
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||
draft: Some(test_puzzle_draft_record()),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
suggested_actions: Vec::new(),
|
||||
result_preview: None,
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
{
|
||||
let draft = session.draft.as_mut().expect("draft");
|
||||
draft.work_title = "猫画面".to_string();
|
||||
draft.work_description = String::new();
|
||||
draft.summary = String::new();
|
||||
draft.theme_tags = Vec::new();
|
||||
}
|
||||
let metadata = PuzzleLevelNaming {
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
|
||||
work_tags: vec![
|
||||
"插画".to_string(),
|
||||
"灯牌".to_string(),
|
||||
"街角".to_string(),
|
||||
"猫咪".to_string(),
|
||||
"暖色".to_string(),
|
||||
"雨夜".to_string(),
|
||||
],
|
||||
ui_background_prompt: None,
|
||||
};
|
||||
|
||||
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||
session,
|
||||
&metadata,
|
||||
"猫画面",
|
||||
1_713_686_401_234_568,
|
||||
);
|
||||
|
||||
let draft = session.draft.expect("draft");
|
||||
assert_eq!(draft.work_title, "雨夜猫街");
|
||||
assert_eq!(
|
||||
draft.work_description,
|
||||
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
|
||||
);
|
||||
assert_eq!(draft.summary, draft.work_description);
|
||||
assert_eq!(draft.theme_tags, metadata.work_tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||
let level = PuzzleDraftLevelResponse {
|
||||
@@ -5981,6 +6469,40 @@ fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> bool {
|
||||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
fn should_use_puzzle_reference_image_edit(
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
|
||||
Reference in New Issue
Block a user