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:
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user