Add generationStatus and match3d/runtime fixes

Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
error
}
fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
}
async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
@@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates(
"message": "AI 重绘需要提供参考图。",
}))
})?;
create_puzzle_vector_engine_image_edit(
let edit_result = create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
@@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates(
count,
reference_image,
)
.await
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
@@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates(
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
None,
)
.await
}
@@ -4263,6 +4296,7 @@ mod tests {
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
@@ -4278,6 +4312,40 @@ mod tests {
);
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
};
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
"参考图里的小猫做成拼图主图。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
1,
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(
images[0]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
@@ -4363,6 +4431,39 @@ mod tests {
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
@@ -4834,6 +4935,7 @@ mod tests {
);
assert_eq!(response.levels.len(), 1);
assert_eq!(response.generation_status.as_deref(), Some("ready"));
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
@@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Result<PuzzleGeneratedImages, AppError> {
let request_body = build_puzzle_vector_engine_image_request_body(
image_model,
@@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation(
negative_prompt,
size,
candidate_count,
reference_image,
);
let request_url = puzzle_vector_engine_images_generation_url(settings);
let request_started_at = Instant::now();
@@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation(
status = status.as_u16(),
prompt_chars = prompt.chars().count(),
size,
has_reference_image = false,
has_reference_image = reference_image.is_some(),
elapsed_ms = upstream_elapsed_ms,
"拼图 VectorEngine 图片生成 HTTP 返回"
);
@@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body(
negative_prompt: &str,
size: &str,
candidate_count: u32,
reference_image: Option<&PuzzleResolvedReferenceImage>,
) -> Value {
Value::Object(Map::from_iter([
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(image_model.request_model_name().to_string()),
@@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body(
),
("n".to_string(), json!(candidate_count.clamp(1, 1))),
("size".to_string(), Value::String(size.to_string())),
]))
]);
if let Some(reference_image) = reference_image
&& let Some(reference_data_url) =
build_puzzle_generation_reference_image_data_url(reference_image)
{
body.insert("image".to_string(), json!([reference_data_url]));
}
Value::Object(body)
}
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
@@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag
)
}
fn build_puzzle_generation_reference_image_data_url(
image: &PuzzleResolvedReferenceImage,
) -> Option<String> {
let bytes = resize_puzzle_generation_reference_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)
))
}
fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
let image = image::load_from_memory(bytes).ok()?;
let resized = image.resize(1024, 1024, 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())
}
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
reference_image_src
.map(str::trim)
@@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error(
) -> AppError {
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
let status = if is_timeout {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
tracing::warn!(
provider = VECTOR_ENGINE_PROVIDER,
upstream_status = upstream_status.as_u16(),
timeout = is_timeout,
message = %message,
raw_excerpt = %raw_excerpt,
"拼图 VectorEngine 上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
AppError::from_status(status).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
"timeout": is_timeout,
}))
}