diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 64665dd61..cb9ca3b62 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -110,11 +110,13 @@ const allowedUncalledTauriCommands = [ 'commit_local_project_asset', 'create_local_project_asset_canvas_draft', 'discard_local_project_asset_canvas_draft', + 'generate_local_project_asset_canvas_image', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_local_project_asset_canvas_draft', 'read_local_project_asset_canvas_media', 'recover_local_project_asset_canvas_transactions', + 'recover_local_project_asset_canvas_generations', 'stage_local_project_asset_canvas_image', 'store_local_project_asset_canvas_media', 'update_local_project_asset_canvas_draft', diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index ab0716f39..7e9ce1c2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -2561,6 +2561,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4012,6 +4022,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -5768,6 +5779,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 8188c853a..94deecd79 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -38,7 +38,7 @@ similar = "2.7" platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" -reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index fad602c11..2831f11fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -13,14 +13,23 @@ mod run_lifecycle; mod tests; mod trace; +pub(crate) use canvas_generation::{ + classify_external_generation_initial_response, external_canvas_placeholder, + external_editor_json_request, external_editor_response_data, external_generation_poll_after_ms, + external_generation_result_has_download_reference, + external_generation_submit_rejection_is_definitive, + platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context, + submit_external_generation_request, wait_for_external_generation_result, + ExternalCanvasGenerationContext, ExternalGenerationInitialResponse, +}; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, - platform_art_generation_error_needs_reconciliation, platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; +pub(crate) use external_generation_state::platform_art_generation_external_configuration_fingerprint; pub(in crate::agent) use external_generation_state::{ game_creator_agent_runtime_external_generation_exists, platform_art_generation_runtime_context_from_pending, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 5c9613bdd..f01fe757c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -268,23 +268,25 @@ pub(crate) fn platform_art_asset_output_extension_matches( } #[derive(Clone, Debug, Eq, PartialEq)] -struct ExternalCanvasGenerationContext { - project_id: String, - asset_folder_id: String, - canvas_name: String, +pub(crate) struct ExternalCanvasGenerationContext { + pub(crate) project_id: String, + pub(crate) asset_folder_id: String, + pub(crate) canvas_name: String, } -fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value { +pub(crate) fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value { payload.get("data").unwrap_or(payload) } #[derive(Clone, Debug, Eq, PartialEq)] -enum ExternalGenerationInitialResponse { +pub(crate) enum ExternalGenerationInitialResponse { LegacyCompleted(serde_json::Value), AsyncSubmission(serde_json::Value), } -fn external_generation_result_has_download_reference(generated: &serde_json::Value) -> bool { +pub(crate) fn external_generation_result_has_download_reference( + generated: &serde_json::Value, +) -> bool { let has_download_reference = |value: &serde_json::Value| { json_string_field(value, "objectKey").is_some() || json_string_field(value, "imageSrc").is_some_and(|image_src| { @@ -307,7 +309,7 @@ fn external_generation_result_has_download_reference(generated: &serde_json::Val .is_some_and(has_download_reference) } -fn external_generation_download_source( +pub(crate) fn external_generation_download_source( generated: &serde_json::Value, resource: &serde_json::Value, is_canonical_art_spritesheet: bool, @@ -371,7 +373,7 @@ fn consistent_canvas_task_id( Ok(resolved) } -fn classify_external_generation_initial_response( +pub(crate) fn classify_external_generation_initial_response( status: reqwest::StatusCode, payload: &serde_json::Value, ) -> Result { @@ -405,7 +407,7 @@ fn classify_external_generation_initial_response( } } -pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool { +pub(crate) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool { error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) || error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX) || error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX) @@ -415,7 +417,7 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } -async fn external_editor_json_request( +pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, ) -> Result { @@ -433,7 +435,7 @@ async fn external_editor_json_request( .map_err(|error| format!("解析{action}响应失败:{error}")) } -fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { +pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") .and_then(serde_json::Value::as_u64) @@ -442,7 +444,9 @@ fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { .min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS) } -fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCode) -> bool { +pub(crate) fn external_generation_submit_rejection_is_definitive( + status: reqwest::StatusCode, +) -> bool { matches!( status, reqwest::StatusCode::BAD_REQUEST @@ -451,7 +455,7 @@ fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCod ) } -async fn wait_for_external_generation_result( +pub(crate) async fn wait_for_external_generation_result( client: &reqwest::Client, api_base_url: &str, api_key: &str, @@ -541,7 +545,7 @@ async fn wait_for_external_generation_result( } } -async fn submit_external_generation_request( +pub(crate) async fn submit_external_generation_request( client: &reqwest::Client, api_base_url: &str, endpoint: &str, @@ -630,7 +634,7 @@ async fn resume_prepared_external_generation_at( } } -async fn prepare_external_canvas_generation_context( +pub(crate) async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, api_base_url: &str, @@ -721,7 +725,7 @@ async fn prepare_external_canvas_generation_context( }) } -fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value { +pub(crate) fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value { let (width, height) = match aspect_ratio { "16:9" => (1024, 576), "9:16" => (576, 1024), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 0de156f4f..3d74ac261 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -122,7 +122,7 @@ fn request_body_json_and_sha256( Ok((request_body_json, request_body_sha256)) } -pub(super) fn platform_art_generation_external_configuration_fingerprint( +pub(crate) fn platform_art_generation_external_configuration_fingerprint( api_base_url: &str, api_key: &str, ) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 210a325f0..5ef6c88ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -535,7 +535,7 @@ fn external_asset_resolved_addresses_are_safe( .all(|address| external_asset_ip_is_proxy_benchmark(address.ip())) } -fn validate_external_asset_download_url( +pub(crate) fn validate_external_asset_download_url( value: &str, api_base_url: &str, _came_from_stable_reference: bool, @@ -558,7 +558,7 @@ fn validate_external_asset_download_url( Ok(url) } -async fn build_external_asset_download_client( +pub(crate) async fn build_external_asset_download_client( url: &url::Url, api_base_url: &str, came_from_stable_reference: bool, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 2601448d4..a11245c16 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -272,6 +272,52 @@ pub(crate) fn stage_local_project_asset_canvas_image( stage_asset_canvas_image_at(&root, &input) } +#[tauri::command] +pub(crate) async fn generate_local_project_asset_canvas_image( + app: tauri::AppHandle, + input: GenerateAssetCanvasImageInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let progress_app = app.clone(); + let execution = generate_asset_canvas_image_at(&root, &input, move |payload| { + let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload); + }) + .await?; + if let Some(event) = execution.event.as_ref() { + publish_asset_canvas_event_after_commit_at(&root, event, |payload| { + app.emit( + "game-creator-local-asset-committed", + asset_canvas_committed_public_event(payload), + ) + .map_err(|error| error.to_string()) + }); + } + Ok(execution.result) +} + +#[tauri::command] +pub(crate) async fn recover_local_project_asset_canvas_generations( + app: tauri::AppHandle, + input: RecoverAssetCanvasGenerationsInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + let progress_app = app.clone(); + let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| { + let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload); + }) + .await?; + for event in &execution.events { + publish_asset_canvas_event_after_commit_at(&root, event, |payload| { + app.emit( + "game-creator-local-asset-committed", + asset_canvas_committed_public_event(payload), + ) + .map_err(|error| error.to_string()) + }); + } + Ok(execution.result) +} + #[tauri::command] pub(crate) fn read_local_project_asset_canvas_media( input: ReadAssetCanvasMediaInput, @@ -297,8 +343,11 @@ pub(crate) fn commit_local_project_asset( let execution = commit_asset_canvas_at(&root, &input)?; if let Some(event) = execution.event.as_ref() { publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit("game-creator-local-asset-committed", payload) - .map_err(|error| error.to_string()) + app.emit( + "game-creator-local-asset-committed", + asset_canvas_committed_public_event(payload), + ) + .map_err(|error| error.to_string()) }); } Ok(execution.result) @@ -313,8 +362,11 @@ pub(crate) fn recover_local_project_asset_canvas_transactions( let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?; for event in &execution.events { publish_asset_canvas_event_after_commit_at(&root, event, |payload| { - app.emit("game-creator-local-asset-committed", payload) - .map_err(|error| error.to_string()) + app.emit( + "game-creator-local-asset-committed", + asset_canvas_committed_public_event(payload), + ) + .map_err(|error| error.to_string()) }); } Ok(execution.result) diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index f3fca202e..4862724ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2237,6 +2237,8 @@ fn main() { update_local_project_asset_canvas_draft, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, + generate_local_project_asset_canvas_image, + recover_local_project_asset_canvas_generations, read_local_project_asset_canvas_media, discard_local_project_asset_canvas_draft, recover_local_project_asset_canvas_transactions, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index e3cea5afe..d212f6dcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -6,6 +6,9 @@ use std::fs::File; use unicode_normalization::UnicodeNormalization; use uuid::Uuid; +mod generation; +pub(crate) use generation::*; + const ASSET_CANVAS_SCHEMA_VERSION: &str = "game-creator-asset-canvas-draft.v1"; const ASSET_CANVAS_COMMIT_SCHEMA_VERSION: &str = "game-creator-local-asset-commit.v1"; const ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION: &str = "game-creator-local-asset-transaction.v1"; @@ -109,6 +112,24 @@ pub(crate) struct AssetCanvasState { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum AssetCanvasGenerationStatus { + GenerationAccepted, + GenerationRunning, + RemoteCompleted, + MediaDownloaded, + AssetDurableCommitted, + Failed, + ReconciliationRequired, +} + +impl Default for AssetCanvasGenerationStatus { + fn default() -> Self { + Self::GenerationAccepted + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum LegacyAssetCanvasGenerationStatus { Accepted, Polling, Completed, @@ -119,22 +140,34 @@ pub(crate) enum AssetCanvasGenerationStatus { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AssetCanvasGenerationRecord { pub(crate) generation_id: String, - pub(crate) idempotency_key: String, - pub(crate) status: AssetCanvasGenerationStatus, - pub(crate) prompt: String, - pub(crate) operation_id: Option, + #[serde(default)] + pub(crate) intent_id: String, + #[serde(default)] + pub(crate) phase: AssetCanvasGenerationStatus, pub(crate) reference_resource_ids: Vec, - pub(crate) output_media_ids: Vec, + #[serde(default)] + pub(crate) output_asset_id: Option, pub(crate) error_code: Option, pub(crate) created_at: u64, pub(crate) updated_at: u64, + #[serde(default, skip_serializing)] + idempotency_key: Option, + #[serde(default, skip_serializing)] + status: Option, + #[serde(default, skip_serializing)] + prompt: Option, + #[serde(default, skip_serializing)] + operation_id: Option, + #[serde(default, skip_serializing)] + output_media_ids: Vec, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AssetCanvasPendingCommit { pub(crate) commit_id: String, - pub(crate) idempotency_key: String, + #[serde(default, skip_serializing)] + idempotency_key: Option, pub(crate) request_fingerprint: String, } @@ -142,7 +175,8 @@ pub(crate) struct AssetCanvasPendingCommit { #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AssetCanvasLastCommit { pub(crate) commit_id: String, - pub(crate) idempotency_key: String, + #[serde(default, skip_serializing)] + idempotency_key: Option, pub(crate) asset_id: String, pub(crate) event_id: String, pub(crate) committed_project_revision: u64, @@ -519,15 +553,30 @@ fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Resul )); } let mut ids = HashSet::with_capacity(records.len()); + let mut intent_ids = HashSet::with_capacity(records.len()); for record in records { validate_uuid_v4(&record.generation_id, "generationId")?; - validate_uuid_v4(&record.idempotency_key, "generation idempotencyKey")?; + validate_uuid_v4(&record.intent_id, "intentId")?; if !ids.insert(record.generation_id.as_str()) - || record.prompt.chars().count() > 32_000 + || !intent_ids.insert(record.intent_id.as_str()) || record.reference_resource_ids.len() > ASSET_CANVAS_MAX_REFERENCES + || record + .output_asset_id + .as_deref() + .is_some_and(|value| validate_plain_component(value, "outputAssetId", 512).is_err()) { return Err("素材画布生成记录无效".to_string()); } + if normalize_asset_canvas_references(&record.reference_resource_ids)? + != record.reference_resource_ids + { + return Err("素材画布生成引用必须是规范化且去重后的稳定身份".to_string()); + } + validate_safe_revision(record.created_at, "generation.createdAt")?; + validate_safe_revision(record.updated_at, "generation.updatedAt")?; + if record.updated_at < record.created_at { + return Err("素材画布生成记录时间顺序无效".to_string()); + } } Ok(()) } @@ -611,8 +660,15 @@ fn read_asset_canvas_draft_candidate( else { return Ok(None); }; - let draft = serde_json::from_slice::(&bytes) + let mut draft = serde_json::from_slice::(&bytes) .map_err(|error| format!("解析 {label} 失败:{error}"))?; + normalize_legacy_generation_records(&mut draft.generations); + if let Some(pending) = draft.pending_commit.as_mut() { + pending.idempotency_key = None; + } + if let Some(last) = draft.last_commit.as_mut() { + last.idempotency_key = None; + } validate_asset_canvas_draft(&draft, project_id, draft_id)?; let manifest = current_asset_canvas_manifest(root)?; if manifest.project_id != project_id { @@ -622,6 +678,34 @@ fn read_asset_canvas_draft_candidate( Ok(Some((draft, bytes))) } +fn normalize_legacy_generation_records(records: &mut [AssetCanvasGenerationRecord]) { + for record in records { + if record.intent_id.is_empty() { + record.intent_id = record.generation_id.clone(); + } + if let Some(status) = record.status.take() { + record.phase = match status { + LegacyAssetCanvasGenerationStatus::Accepted => { + AssetCanvasGenerationStatus::GenerationAccepted + } + LegacyAssetCanvasGenerationStatus::Polling => { + AssetCanvasGenerationStatus::GenerationRunning + } + LegacyAssetCanvasGenerationStatus::Completed => { + AssetCanvasGenerationStatus::AssetDurableCommitted + } + LegacyAssetCanvasGenerationStatus::Failed => AssetCanvasGenerationStatus::Failed, + }; + } + if record.output_asset_id.is_none() { + record.output_asset_id = record.output_media_ids.first().cloned(); + } + record.reference_resource_ids = + normalize_asset_canvas_references(&record.reference_resource_ids) + .unwrap_or_else(|_| record.reference_resource_ids.clone()); + } +} + fn validate_asset_canvas_draft_media( root: &Path, manifest: &GameCreationAppManifest, @@ -1317,6 +1401,14 @@ pub(crate) fn store_asset_canvas_media_at( pub(crate) fn stage_asset_canvas_image_at( root: &Path, input: &StageAssetCanvasImageInput, +) -> Result { + stage_asset_canvas_image_with_token_at(root, input, None) +} + +fn stage_asset_canvas_image_with_token_at( + root: &Path, + input: &StageAssetCanvasImageInput, + stable_token: Option<&str>, ) -> Result { validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?; @@ -1340,7 +1432,42 @@ pub(crate) fn stage_asset_canvas_image_at( draft: Some(draft), }); } - let token = new_asset_canvas_token()?; + let token = match stable_token { + Some(token) => { + validate_uuid_v4(token, "stagedImageToken")?; + token.to_string() + } + None => new_asset_canvas_token()?, + }; + if stable_token.is_some() { + match read_staged_image_locked(root, &token) { + Ok((metadata, existing_bytes)) => { + if metadata.project_id != manifest.project_id + || metadata.draft_id != input.draft_id + || metadata.draft_revision != draft.revision + || metadata.media_type != media_type + || existing_bytes != input.bytes + { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + return Ok(StageAssetCanvasImageResult { + status: "staged".to_string(), + staged_image_token: Some(token), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_type: Some(metadata.media_type), + sha256: Some(metadata.sha256), + byte_length: Some(metadata.byte_length), + pixel_width: Some(metadata.pixel_width), + pixel_height: Some(metadata.pixel_height), + expires_at: Some(metadata.expires_at), + draft: None, + }); + } + Err(error) if !error.contains("不存在") => return Err(error), + Err(_) => {} + } + } let extension = media_extension(&media_type)?; let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"); install_new_asset_canvas_file( @@ -1620,6 +1747,23 @@ pub(crate) struct AssetCanvasCommittedEvent { pub(crate) occurred_at: u64, } +pub(crate) fn asset_canvas_committed_public_event( + event: &AssetCanvasCommittedEvent, +) -> serde_json::Value { + serde_json::json!({ + "schemaVersion": event.schema_version, + "eventId": event.event_id, + "projectPath": event.project_path, + "projectId": event.project_id, + "committedProjectRevision": event.committed_project_revision, + "draftId": event.draft_id, + "commitId": event.commit_id, + "asset": event.asset, + "manifest": event.manifest, + "occurredAt": event.occurred_at, + }) +} + impl AssetCanvasCommittedEventBody { fn with_project_path(&self, root: &Path) -> AssetCanvasCommittedEvent { AssetCanvasCommittedEvent { @@ -2507,7 +2651,7 @@ fn commit_asset_canvas_at_internal( draft.pending_commit = None; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: input.commit_id.clone(), - idempotency_key: input.idempotency_key.clone(), + idempotency_key: None, asset_id: asset_id.clone(), event_id: event_id.clone(), committed_project_revision: target_project_revision, @@ -2758,7 +2902,6 @@ fn finish_recovered_asset_canvas_commit_locked( .ok_or_else(|| "恢复提交时草稿不存在".to_string())?; let already_matches = draft.last_commit.as_ref().is_some_and(|last| { last.commit_id == journal.commit_id - && last.idempotency_key == journal.idempotency_key && last.asset_id == journal.asset_id && last.event_id == journal.event_id && last.committed_project_revision == journal.target_project_revision @@ -2772,7 +2915,7 @@ fn finish_recovered_asset_canvas_commit_locked( draft.pending_commit = None; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: journal.commit_id.clone(), - idempotency_key: journal.idempotency_key.clone(), + idempotency_key: None, asset_id: journal.asset_id.clone(), event_id: journal.event_id.clone(), committed_project_revision: journal.target_project_revision, @@ -2852,7 +2995,7 @@ fn recover_asset_canvas_transaction_locked( draft.status = AssetCanvasDraftStatus::Committed; draft.last_commit = Some(AssetCanvasLastCommit { commit_id: journal.commit_id.clone(), - idempotency_key: journal.idempotency_key.clone(), + idempotency_key: None, asset_id: journal.asset_id.clone(), event_id: journal.event_id.clone(), committed_project_revision: journal.target_project_revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs new file mode 100644 index 000000000..76e430b68 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -0,0 +1,2396 @@ +use super::*; +use reqwest::multipart::{Form, Part}; +use std::collections::{BTreeMap, HashMap}; + +const ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION: &str = + "game-creator-asset-canvas-generation.v1"; +pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str = + "game-creator-asset-generation-progress"; +const ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES: usize = 512 * 1024; +const ASSET_CANVAS_GENERATION_REFERENCE_LIMIT: usize = 9; + +static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< + tokio::sync::Mutex>>>, +> = OnceLock::new(); + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct GenerateAssetCanvasImageInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_host_revision: u64, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) intent_id: String, + pub(crate) generation_id: String, + pub(crate) idempotency_key: String, + pub(crate) commit_id: String, + pub(crate) commit_idempotency_key: String, + pub(crate) prompt: String, + pub(crate) aspect_ratio: String, + pub(crate) image_size: String, + pub(crate) asset_kind: String, + pub(crate) asset_name: String, + pub(crate) reference_resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct RecoverAssetCanvasGenerationsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetCanvasGenerationProgressEvent { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) draft_id: String, + pub(crate) intent_id: String, + pub(crate) generation_id: String, + pub(crate) phase: String, + pub(crate) progress: Option, + pub(crate) error_code: Option, + pub(crate) occurred_at: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetCanvasGenerationCommitResult { + pub(crate) resource_id: String, + pub(crate) asset_id: String, + pub(crate) project_id: String, + pub(crate) commit_id: String, + pub(crate) committed_project_revision: u64, + pub(crate) draft_revision: u64, + pub(crate) host_revision: String, + pub(crate) commit_status: String, + pub(crate) manifest: GameCreationAppManifest, + pub(crate) event_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GenerateAssetCanvasImageResult { + pub(crate) generation: AssetCanvasGenerationRecord, + pub(crate) images: Vec, + pub(crate) commit: AssetCanvasGenerationCommitResult, +} + +pub(crate) struct GenerateAssetCanvasImageExecution { + pub(crate) result: GenerateAssetCanvasImageResult, + pub(crate) event: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RecoverAssetCanvasGenerationsResult { + pub(crate) resumed_generation_ids: Vec, +} + +pub(crate) struct RecoverAssetCanvasGenerationsExecution { + pub(crate) result: RecoverAssetCanvasGenerationsResult, + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum GenerationLedgerPhase { + ContextPreparing, + ReferencesPreparing, + Prepared, + Accepted, + Running, + RemoteCompleted, + MediaDownloaded, + AssetDurableCommitted, + Failed, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateCanvasContext { + project_id: String, + asset_folder_id: String, + canvas_name: String, +} + +impl From for PrivateCanvasContext { + fn from(value: ExternalCanvasGenerationContext) -> Self { + Self { + project_id: value.project_id, + asset_folder_id: value.asset_folder_id, + canvas_name: value.canvas_name, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateUploadTicket { + host: String, + bucket: String, + object_key: String, + success_action_status: u16, + max_size_bytes: u64, + form_fields: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateReferenceState { + resource_id: String, + stable_reference: Option, + asset_object_id: Option, + upload_ticket: Option, + upload_completed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateRemoteResult { + resource_id: String, + object_key: String, + asset_object_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateCommitResult { + resource_id: String, + asset_id: String, + project_id: String, + commit_id: String, + committed_project_revision: u64, + draft_revision: u64, + host_revision: u64, + commit_status: String, + event_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AssetCanvasGenerationLedger { + schema_version: String, + request_fingerprint: String, + project_id: String, + draft_id: String, + intent: AssetCanvasIntent, + source_asset_id: Option, + source_resource_id: Option, + intent_id: String, + generation_id: String, + idempotency_key: String, + commit_id: String, + commit_idempotency_key: String, + expected_host_revision: u64, + expected_draft_revision: u64, + prompt: String, + aspect_ratio: String, + image_size: String, + asset_kind: String, + asset_name: String, + requested_reference_resource_ids: Vec, + reference_states: Vec, + resolved_reference_ids: Vec, + external_configuration_fingerprint: Option, + canvas_context: Option, + endpoint: Option, + request_body_sha256: Option, + request_body_json: Option, + phase: GenerationLedgerPhase, + operation_id: Option, + poll_after_ms: Option, + remote_result: Option, + staged_image_token: String, + commit_result: Option, + error_code: Option, + created_at: u64, + updated_at: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct NormalizedGenerationRequest<'a> { + project_id: &'a str, + draft_id: &'a str, + intent: &'a AssetCanvasIntent, + source_asset_id: &'a Option, + intent_id: &'a str, + generation_id: &'a str, + idempotency_key: &'a str, + commit_id: &'a str, + commit_idempotency_key: &'a str, + expected_host_revision: u64, + expected_draft_revision: u64, + prompt: &'a str, + aspect_ratio: &'a str, + image_size: &'a str, + asset_kind: &'a str, + asset_name: &'a str, + reference_resource_ids: &'a [String], +} + +struct ReferenceMaterial { + stable_reference: Option, + bytes: Option>, + media_type: Option, + file_name: Option, + sha256: Option, +} + +fn generation_ledger_relative_path(generation_id: &str) -> String { + format!("{ASSET_CANVAS_ROOT}/generations/{generation_id}.json") +} + +fn generation_progress_event( + ledger: &AssetCanvasGenerationLedger, + phase: &str, + progress: Option, + error_code: Option, +) -> AssetCanvasGenerationProgressEvent { + AssetCanvasGenerationProgressEvent { + schema_version: "game-creator-asset-generation-progress.v1".to_string(), + project_id: ledger.project_id.clone(), + draft_id: ledger.draft_id.clone(), + intent_id: ledger.intent_id.clone(), + generation_id: ledger.generation_id.clone(), + phase: phase.to_string(), + progress, + error_code, + occurred_at: asset_canvas_now(), + } +} + +fn public_phase(phase: &GenerationLedgerPhase) -> Option { + match phase { + GenerationLedgerPhase::Accepted => Some(AssetCanvasGenerationStatus::GenerationAccepted), + GenerationLedgerPhase::Running => Some(AssetCanvasGenerationStatus::GenerationRunning), + GenerationLedgerPhase::RemoteCompleted => { + Some(AssetCanvasGenerationStatus::RemoteCompleted) + } + GenerationLedgerPhase::MediaDownloaded => { + Some(AssetCanvasGenerationStatus::MediaDownloaded) + } + GenerationLedgerPhase::AssetDurableCommitted => { + Some(AssetCanvasGenerationStatus::AssetDurableCommitted) + } + GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed), + GenerationLedgerPhase::ReconciliationRequired => { + Some(AssetCanvasGenerationStatus::ReconciliationRequired) + } + GenerationLedgerPhase::ContextPreparing + | GenerationLedgerPhase::ReferencesPreparing + | GenerationLedgerPhase::Prepared => None, + } +} + +fn public_phase_name(phase: &AssetCanvasGenerationStatus) -> &'static str { + match phase { + AssetCanvasGenerationStatus::GenerationAccepted => "generation-accepted", + AssetCanvasGenerationStatus::GenerationRunning => "generation-running", + AssetCanvasGenerationStatus::RemoteCompleted => "remote-completed", + AssetCanvasGenerationStatus::MediaDownloaded => "media-downloaded", + AssetCanvasGenerationStatus::AssetDurableCommitted => "asset-durable-committed", + AssetCanvasGenerationStatus::Failed => "failed", + AssetCanvasGenerationStatus::ReconciliationRequired => "reconciliation-required", + } +} + +fn public_progress_value(phase: &AssetCanvasGenerationStatus) -> Option { + match phase { + AssetCanvasGenerationStatus::GenerationAccepted => Some(10), + AssetCanvasGenerationStatus::GenerationRunning => Some(35), + AssetCanvasGenerationStatus::RemoteCompleted => Some(60), + AssetCanvasGenerationStatus::MediaDownloaded => Some(80), + AssetCanvasGenerationStatus::AssetDurableCommitted => Some(100), + AssetCanvasGenerationStatus::Failed + | AssetCanvasGenerationStatus::ReconciliationRequired => None, + } +} + +fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<(), String> { + if ledger.schema_version != ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION { + return Err("素材画布私有生成账本版本无效".to_string()); + } + for (value, label) in [ + (&ledger.intent_id, "intentId"), + (&ledger.generation_id, "generationId"), + (&ledger.idempotency_key, "idempotencyKey"), + (&ledger.commit_id, "commitId"), + (&ledger.commit_idempotency_key, "commitIdempotencyKey"), + (&ledger.staged_image_token, "stagedImageToken"), + ] { + validate_uuid_v4(value, label)?; + } + validate_safe_revision(ledger.expected_host_revision, "expectedHostRevision")?; + validate_safe_revision(ledger.expected_draft_revision, "expectedDraftRevision")?; + if ledger.prompt.is_empty() + || ledger.prompt.chars().count() > 32_000 + || ledger.request_fingerprint.len() != 64 + || ledger.reference_states.len() > ASSET_CANVAS_GENERATION_REFERENCE_LIMIT + || ledger.resolved_reference_ids.len() > ASSET_CANVAS_GENERATION_REFERENCE_LIMIT + { + return Err("素材画布私有生成账本内容无效".to_string()); + } + if let (Some(body), Some(expected_sha)) = ( + ledger.request_body_json.as_deref(), + ledger.request_body_sha256.as_deref(), + ) { + if asset_canvas_sha256(body.as_bytes()) != expected_sha { + return Err("素材画布私有生成请求快照摘要不匹配".to_string()); + } + } else if ledger.request_body_json.is_some() || ledger.request_body_sha256.is_some() { + return Err("素材画布私有生成请求快照不完整".to_string()); + } + Ok(()) +} + +fn read_generation_ledger( + root: &Path, + generation_id: &str, +) -> Result, String> { + validate_uuid_v4(generation_id, "generationId")?; + let ledger = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &generation_ledger_relative_path(generation_id), + "素材画布私有生成账本", + ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES, + )?; + if let Some(ledger) = ledger.as_ref() { + validate_generation_ledger(ledger)?; + } + Ok(ledger) +} + +fn write_generation_ledger( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result<(), String> { + ledger.updated_at = asset_canvas_now(); + validate_generation_ledger(ledger)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &generation_ledger_relative_path(&ledger.generation_id), + "素材画布私有生成账本", + ledger, + ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES, + ) +} + +fn set_private_phase( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + phase: GenerationLedgerPhase, + error_code: Option<&str>, +) -> Result<(), String> { + ledger.phase = phase; + ledger.error_code = error_code.map(str::to_string); + write_generation_ledger(root, ledger) +} + +fn upsert_public_generation_record( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result { + let phase = + public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.project_id != ledger.project_id + || draft.intent != ledger.intent + || draft.source_asset_id != ledger.source_asset_id + { + return Err("素材画布生成账本与草稿身份不一致".to_string()); + } + let now = asset_canvas_now(); + let output_asset_id = ledger + .commit_result + .as_ref() + .map(|result| result.asset_id.clone()); + let references = normalize_asset_canvas_references(&ledger.requested_reference_resource_ids)?; + let index = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id); + let record = AssetCanvasGenerationRecord { + generation_id: ledger.generation_id.clone(), + intent_id: ledger.intent_id.clone(), + phase, + reference_resource_ids: references, + output_asset_id, + error_code: ledger.error_code.clone(), + created_at: index + .and_then(|index| draft.generations.get(index)) + .map(|record| record.created_at) + .unwrap_or(ledger.created_at), + updated_at: now, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }; + if let Some(index) = index { + let current = &draft.generations[index]; + if current.intent_id != ledger.intent_id { + return Err("generationId 已绑定到不同 intentId".to_string()); + } + draft.generations[index] = record.clone(); + } else { + if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS { + return Err("素材画布生成记录数量已达上限".to_string()); + } + draft.generations.push(record.clone()); + } + if ledger.phase == GenerationLedgerPhase::Failed { + draft.status = AssetCanvasDraftStatus::Editing; + } else if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted { + draft.status = AssetCanvasDraftStatus::Generating; + } + draft.updated_at = now; + write_asset_canvas_draft_locked(root, &draft)?; + Ok(record) +} + +fn publish_public_phase( + root: &Path, + ledger: &AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let record = upsert_public_generation_record(root, ledger)?; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(record) +} + +fn normalized_request_fingerprint( + input: &GenerateAssetCanvasImageInput, + manifest: &GameCreationAppManifest, + draft: &AssetCanvasDraft, + prompt: &str, + asset_kind: &str, + asset_name: &str, + references: &[String], +) -> Result { + let request = NormalizedGenerationRequest { + project_id: &manifest.project_id, + draft_id: &input.draft_id, + intent: &draft.intent, + source_asset_id: &draft.source_asset_id, + intent_id: &input.intent_id, + generation_id: &input.generation_id, + idempotency_key: &input.idempotency_key, + commit_id: &input.commit_id, + commit_idempotency_key: &input.commit_idempotency_key, + expected_host_revision: input.expected_host_revision, + expected_draft_revision: input.expected_draft_revision, + prompt, + aspect_ratio: &input.aspect_ratio, + image_size: &input.image_size, + asset_kind, + asset_name, + reference_resource_ids: references, + }; + serde_json::to_vec(&request) + .map(|bytes| asset_canvas_sha256(&bytes)) + .map_err(|_| "无法建立素材画布生成请求指纹".to_string()) +} + +fn validate_generation_identity_uniqueness( + root: &Path, + input: &GenerateAssetCanvasImageInput, +) -> Result<(), String> { + let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/generations"))?; + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), + }; + let mut count = 0_usize; + for entry in entries { + let entry = entry.map_err(|_| "读取素材画布私有生成账本失败".to_string())?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + count += 1; + if count > ASSET_CANVAS_MAX_GENERATIONS { + return Err("素材画布私有生成账本数量超过上限".to_string()); + } + let generation_id = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "素材画布私有生成账本文件名无效".to_string())?; + if generation_id == input.generation_id { + continue; + } + let ledger = read_generation_ledger(root, generation_id)? + .ok_or_else(|| "素材画布私有生成账本读取结果不一致".to_string())?; + if ledger.intent_id == input.intent_id + || ledger.idempotency_key == input.idempotency_key + || ledger.commit_id == input.commit_id + || ledger.commit_idempotency_key == input.commit_idempotency_key + { + return Err("生成 intentId 或幂等身份已绑定到不同 generationId".to_string()); + } + } + Ok(()) +} + +fn validate_and_prepare_ledger( + root: &Path, + input: &GenerateAssetCanvasImageInput, +) -> Result { + for (value, label) in [ + (&input.draft_id, "draftId"), + (&input.intent_id, "intentId"), + (&input.generation_id, "generationId"), + (&input.idempotency_key, "idempotencyKey"), + (&input.commit_id, "commitId"), + (&input.commit_idempotency_key, "commitIdempotencyKey"), + ] { + validate_uuid_v4(value, label)?; + } + validate_safe_revision(input.expected_host_revision, "expectedHostRevision")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + if !matches!( + input.aspect_ratio.as_str(), + "1:1" | "2:3" | "3:2" | "9:16" | "16:9" + ) { + return Err("图片比例无效".to_string()); + } + if !matches!(input.image_size.as_str(), "0.5K" | "1K" | "2K") { + return Err("图片尺寸无效".to_string()); + } + let prompt = input.prompt.trim(); + if prompt.is_empty() || prompt.chars().count() > 32_000 { + return Err("图片提示词必须在 1..=32000 字符内".to_string()); + } + let asset_kind = validate_asset_kind(&input.asset_kind)?; + let asset_name = validate_asset_canvas_name(&input.asset_name)?; + let mut references = normalize_asset_canvas_references(&input.reference_resource_ids)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.intent == AssetCanvasIntent::Refine { + let source = draft + .source_resource_id + .as_ref() + .ok_or_else(|| "精修草稿缺少源资源身份".to_string())?; + references.push(source.clone()); + references = normalize_asset_canvas_references(&references)?; + } + let reference_limit = if draft.intent == AssetCanvasIntent::Refine { + ASSET_CANVAS_GENERATION_REFERENCE_LIMIT + } else { + ASSET_CANVAS_GENERATION_REFERENCE_LIMIT + }; + if references.len() > reference_limit { + return Err(format!("图片生成最多支持 {reference_limit} 个参考资源")); + } + let fingerprint = normalized_request_fingerprint( + input, + &manifest, + &draft, + prompt, + &asset_kind, + &asset_name, + &references, + )?; + if let Some(ledger) = read_generation_ledger(root, &input.generation_id)? { + if ledger.request_fingerprint != fingerprint + || ledger.project_id != manifest.project_id + || ledger.intent_id != input.intent_id + || ledger.idempotency_key != input.idempotency_key + || ledger.commit_id != input.commit_id + || ledger.commit_idempotency_key != input.commit_idempotency_key + { + return Err("generationId、intentId 或幂等键已绑定到不同生成请求".to_string()); + } + return Ok(ledger); + } + validate_generation_identity_uniqueness(root, input)?; + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if current_revision.revision != input.expected_host_revision { + return Err("project-revision-conflict".to_string()); + } + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + if !matches!( + draft.status, + AssetCanvasDraftStatus::Editing | AssetCanvasDraftStatus::Generating + ) { + return Err("素材画布草稿当前状态不允许生成".to_string()); + } + let now = asset_canvas_now(); + let mut ledger = AssetCanvasGenerationLedger { + schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), + request_fingerprint: fingerprint, + project_id: manifest.project_id, + draft_id: draft.draft_id, + intent: draft.intent, + source_asset_id: draft.source_asset_id, + source_resource_id: draft.source_resource_id, + intent_id: input.intent_id.clone(), + generation_id: input.generation_id.clone(), + idempotency_key: input.idempotency_key.clone(), + commit_id: input.commit_id.clone(), + commit_idempotency_key: input.commit_idempotency_key.clone(), + expected_host_revision: input.expected_host_revision, + expected_draft_revision: input.expected_draft_revision, + prompt: prompt.to_string(), + aspect_ratio: input.aspect_ratio.clone(), + image_size: input.image_size.clone(), + asset_kind, + asset_name, + requested_reference_resource_ids: references, + reference_states: Vec::new(), + resolved_reference_ids: Vec::new(), + external_configuration_fingerprint: None, + canvas_context: None, + endpoint: None, + request_body_sha256: None, + request_body_json: None, + phase: GenerationLedgerPhase::ContextPreparing, + operation_id: None, + poll_after_ms: None, + remote_result: None, + staged_image_token: Uuid::new_v4().to_string(), + commit_result: None, + error_code: None, + created_at: now, + updated_at: now, + }; + write_generation_ledger(root, &mut ledger)?; + Ok(ledger) +} + +fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Option { + asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| { + !value.is_empty() + && !value.starts_with("local-asset:") + && !value.starts_with("draft-media:") + }) + .map(str::to_string) +} + +fn reference_material_at( + root: &Path, + ledger: &AssetCanvasGenerationLedger, + resource_id: &str, +) -> Result { + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if let Some(asset) = manifest.assets.iter().find(|asset| { + asset.id == resource_id + || resource_id.strip_prefix("asset:") == Some(asset.id.as_str()) + || asset_canvas_source_resource_id(asset) == resource_id + || asset.source.resource_id.as_deref() == Some(resource_id) + }) { + if let Some(stable_reference) = stable_manifest_reference(asset) { + return Ok(ReferenceMaterial { + stable_reference: Some(stable_reference), + bytes: None, + media_type: None, + file_name: None, + sha256: None, + }); + } + let (bytes, _, _) = open_and_validate_image_file( + &resolve_local_project_path(root, &asset.local_path)?, + &asset.media_type, + None, + )?; + let extension = media_extension(&asset.media_type)?; + let sha256 = asset_canvas_sha256(&bytes); + return Ok(ReferenceMaterial { + stable_reference: None, + bytes: Some(bytes), + media_type: Some(asset.media_type.clone()), + file_name: Some(format!("reference-{sha256}.{extension}")), + sha256: Some(sha256), + }); + } + let layer = draft + .canvas + .layers + .iter() + .find(|layer| layer.resource_id == resource_id) + .ok_or_else(|| "参考资源不属于当前草稿或项目 manifest".to_string())?; + let (relative_path, media_type, expected_sha256) = + draft_media_relative_path(&draft.draft_id, &layer.media_ref)?; + let (bytes, _, _) = open_and_validate_image_file( + &resolve_local_project_path(root, &relative_path)?, + &media_type, + expected_sha256.as_deref(), + )?; + let extension = media_extension(&media_type)?; + let sha256 = asset_canvas_sha256(&bytes); + Ok(ReferenceMaterial { + stable_reference: None, + bytes: Some(bytes), + media_type: Some(media_type), + file_name: Some(format!("reference-{sha256}.{extension}")), + sha256: Some(sha256), + }) +} + +async fn try_confirm_uploaded_reference( + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + ticket: &PrivateUploadTicket, + material: &ReferenceMaterial, + asset_kind: &str, +) -> Result, String> { + let response = client + .post(format!( + "{api_base_url}/api/external/v1/assets/objects/confirm" + )) + .bearer_auth(api_key) + .json(&serde_json::json!({ + "bucket": ticket.bucket, + "objectKey": ticket.object_key, + "contentType": material.media_type, + "contentLength": material.bytes.as_ref().map(Vec::len), + "contentHash": material.sha256, + "assetKind": asset_kind, + "accessPolicy": "private", + })) + .send() + .await + .map_err(|_| "确认参考资源上传结果失败".to_string())?; + if !response.status().is_success() { + return Ok(None); + } + let payload = response + .json::() + .await + .map_err(|_| "解析参考资源确认响应失败".to_string())?; + let asset_object = external_editor_response_data(&payload) + .get("assetObject") + .or_else(|| payload.pointer("/data/assetObject")) + .unwrap_or(&serde_json::Value::Null); + let confirmed_object_key = json_string_field(asset_object, "objectKey") + .ok_or_else(|| "参考资源确认响应缺少 objectKey".to_string())?; + if confirmed_object_key != ticket.object_key { + return Err("参考资源确认响应 objectKey 不一致".to_string()); + } + json_string_field(asset_object, "assetObjectId") + .map(Some) + .ok_or_else(|| "参考资源确认响应缺少 assetObjectId".to_string()) +} + +async fn request_upload_ticket( + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + ledger: &AssetCanvasGenerationLedger, + material: &ReferenceMaterial, +) -> Result { + let payload = external_editor_json_request( + client + .post(format!( + "{api_base_url}/api/external/v1/assets/direct-upload-tickets" + )) + .bearer_auth(api_key) + .json(&serde_json::json!({ + "legacyPrefix": "asset-canvas-references", + "pathSegments": [ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], + "fileName": material.file_name, + "contentType": material.media_type, + "access": "private", + "maxSizeBytes": material.bytes.as_ref().map(Vec::len), + "successActionStatus": 204, + })), + "创建参考资源上传凭证", + ) + .await + .map_err(|_| "创建参考资源上传凭证失败".to_string())?; + let upload = external_editor_response_data(&payload) + .get("upload") + .or_else(|| payload.pointer("/data/upload")) + .ok_or_else(|| "参考资源上传凭证缺少 upload".to_string())?; + let host = json_string_field(upload, "host") + .or_else(|| json_string_field(upload, "endpoint")) + .ok_or_else(|| "参考资源上传凭证缺少 host".to_string())?; + let bucket = json_string_field(upload, "bucket") + .ok_or_else(|| "参考资源上传凭证缺少 bucket".to_string())?; + let object_key = json_string_field(upload, "objectKey") + .ok_or_else(|| "参考资源上传凭证缺少 objectKey".to_string())?; + let success_action_status = upload + .get("successActionStatus") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| matches!(value, 200 | 201 | 204)) + .ok_or_else(|| "参考资源上传凭证 successActionStatus 无效".to_string())?; + let max_size_bytes = upload + .get("maxSizeBytes") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "参考资源上传凭证缺少 maxSizeBytes".to_string())?; + if material + .bytes + .as_ref() + .is_some_and(|bytes| bytes.len() as u64 > max_size_bytes) + { + return Err("参考资源超过上传凭证大小上限".to_string()); + } + let form_fields = upload + .get("formFields") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "参考资源上传凭证缺少 formFields".to_string())? + .iter() + .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_string()))) + .collect::>(); + if form_fields.is_empty() { + return Err("参考资源上传凭证 formFields 为空".to_string()); + } + Ok(PrivateUploadTicket { + host, + bucket, + object_key, + success_action_status, + max_size_bytes, + form_fields, + }) +} + +async fn upload_reference( + ticket: &PrivateUploadTicket, + material: &ReferenceMaterial, + api_base_url: &str, +) -> Result<(), String> { + let bytes = material + .bytes + .as_ref() + .ok_or_else(|| "参考资源上传缺少图片内容".to_string())?; + let media_type = material + .media_type + .as_deref() + .ok_or_else(|| "参考资源上传缺少 mediaType".to_string())?; + let file_name = material + .file_name + .as_deref() + .ok_or_else(|| "参考资源上传缺少文件名".to_string())?; + let upload_url = validate_external_asset_download_url(&ticket.host, api_base_url, true) + .map_err(|_| "参考资源上传地址无效".to_string())?; + let client = build_external_asset_download_client(&upload_url, api_base_url, true) + .await + .map_err(|_| "创建参考资源安全上传客户端失败".to_string())?; + let mut form = Form::new(); + for (key, value) in &ticket.form_fields { + form = form.text(key.clone(), value.clone()); + } + let part = Part::bytes(bytes.clone()) + .file_name(file_name.to_string()) + .mime_str(media_type) + .map_err(|_| "参考资源 mediaType 无法用于上传".to_string())?; + let response = client + .post(upload_url) + .multipart(form.part("file", part)) + .send() + .await + .map_err(|_| "上传参考资源失败".to_string())?; + if response.status().as_u16() != ticket.success_action_status { + return Err("上传参考资源返回非预期状态".to_string()); + } + Ok(()) +} + +async fn ensure_reference_states( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, +) -> Result<(), String> { + set_private_phase( + root, + ledger, + GenerationLedgerPhase::ReferencesPreparing, + None, + )?; + let requested = ledger.requested_reference_resource_ids.clone(); + for resource_id in requested { + let material = reference_material_at(root, ledger, &resource_id)?; + let index = match ledger + .reference_states + .iter() + .position(|state| state.resource_id == resource_id) + { + Some(index) => index, + None => { + ledger.reference_states.push(PrivateReferenceState { + resource_id: resource_id.clone(), + stable_reference: material.stable_reference.clone(), + asset_object_id: None, + upload_ticket: None, + upload_completed: material.stable_reference.is_some(), + }); + write_generation_ledger(root, ledger)?; + ledger.reference_states.len() - 1 + } + }; + if ledger.reference_states[index].stable_reference.is_some() { + continue; + } + if let Some(stable_reference) = material.stable_reference.clone() { + ledger.reference_states[index].stable_reference = Some(stable_reference); + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; + continue; + } + if let Some(ticket) = ledger.reference_states[index].upload_ticket.clone() { + if let Some(asset_object_id) = try_confirm_uploaded_reference( + client, + api_base_url, + api_key, + &ticket, + &material, + &ledger.asset_kind, + ) + .await? + { + ledger.reference_states[index].stable_reference = Some(ticket.object_key); + ledger.reference_states[index].asset_object_id = Some(asset_object_id); + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; + continue; + } + } + let ticket = + request_upload_ticket(client, api_base_url, api_key, ledger, &material).await?; + ledger.reference_states[index].upload_ticket = Some(ticket.clone()); + ledger.reference_states[index].upload_completed = false; + write_generation_ledger(root, ledger)?; + upload_reference(&ticket, &material, api_base_url).await?; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; + let asset_object_id = try_confirm_uploaded_reference( + client, + api_base_url, + api_key, + &ticket, + &material, + &ledger.asset_kind, + ) + .await? + .ok_or_else(|| "参考资源上传后无法确认稳定对象".to_string())?; + ledger.reference_states[index].stable_reference = Some(ticket.object_key); + ledger.reference_states[index].asset_object_id = Some(asset_object_id); + write_generation_ledger(root, ledger)?; + } + ledger.resolved_reference_ids = ledger + .reference_states + .iter() + .map(|state| { + state + .stable_reference + .clone() + .ok_or_else(|| "参考资源缺少稳定远端引用".to_string()) + }) + .collect::, _>>()?; + write_generation_ledger(root, ledger) +} + +fn external_generation_kind(asset_kind: &str) -> Option<&'static str> { + match asset_kind { + "icon-spec" => Some("spec"), + "character" | "character-art" => Some("character"), + "ui-prototype" | "ui-design" => Some("ui-design"), + "publication-material" => Some("publication-material"), + _ => None, + } +} + +fn build_generation_request_snapshot( + ledger: &AssetCanvasGenerationLedger, +) -> Result<(String, String), String> { + let context = ledger + .canvas_context + .as_ref() + .ok_or_else(|| "素材画布生成缺少 External canvas context".to_string())?; + let placeholder = external_canvas_placeholder(&ledger.aspect_ratio); + let mut body = serde_json::Map::new(); + body.insert("prompt".to_string(), serde_json::json!(ledger.prompt)); + body.insert( + "aspectRatio".to_string(), + serde_json::json!(ledger.aspect_ratio), + ); + body.insert( + "imageSize".to_string(), + serde_json::json!(ledger.image_size), + ); + body.insert( + "assetKind".to_string(), + serde_json::json!(ledger.asset_kind), + ); + body.insert( + "assetLabel".to_string(), + serde_json::json!(ledger.asset_name), + ); + body.insert( + "projectId".to_string(), + serde_json::json!(context.project_id), + ); + body.insert( + "assetFolderId".to_string(), + serde_json::json!(context.asset_folder_id), + ); + body.insert( + "canvasCompletion".to_string(), + serde_json::json!({ "title": ledger.asset_name, "placeholder": placeholder }), + ); + let endpoint = if ledger.intent == AssetCanvasIntent::Refine { + let source_image_src = ledger + .source_resource_id + .as_ref() + .and_then(|resource_id| { + ledger + .reference_states + .iter() + .find(|state| state.resource_id == *resource_id) + }) + .and_then(|state| state.stable_reference.clone()) + .ok_or_else(|| "精修生成缺少稳定源图片引用".to_string())?; + body.insert( + "sourceImageSrc".to_string(), + serde_json::json!(source_image_src), + ); + if let Some(source_resource_id) = ledger + .source_resource_id + .as_deref() + .map(str::trim) + .filter(|value| { + !value.is_empty() + && !value.starts_with("local-asset:") + && !value.starts_with("draft-media:") + }) + { + body.insert( + "sourceResourceId".to_string(), + serde_json::json!(source_resource_id), + ); + } + let additional = ledger + .resolved_reference_ids + .iter() + .filter(|reference| **reference != source_image_src) + .cloned() + .collect::>(); + body.insert( + "referenceImageSrcs".to_string(), + serde_json::json!(additional), + ); + "/api/external/v1/editor/images/edits" + } else { + body.insert( + "referenceImageSrcs".to_string(), + serde_json::json!(ledger.resolved_reference_ids), + ); + if let Some(kind) = external_generation_kind(&ledger.asset_kind) { + body.insert("kind".to_string(), serde_json::json!(kind)); + } + "/api/external/v1/editor/images/generations" + }; + let body_json = serde_json::to_string(&serde_json::Value::Object(body)) + .map_err(|_| "无法序列化 External Editor 生成请求".to_string())?; + Ok((endpoint.to_string(), body_json)) +} + +fn extract_remote_result(generated: &serde_json::Value) -> Result { + if !external_generation_result_has_download_reference(generated) { + return Err("stable-reference-missing".to_string()); + } + let null = serde_json::Value::Null; + let resource = generated + .get("resource") + .filter(|value| value.is_object()) + .unwrap_or(generated); + let asset = generated + .get("asset") + .filter(|value| value.is_object()) + .unwrap_or(&null); + let resource_id = json_string_field(resource, "resourceId") + .or_else(|| json_string_field(generated, "resourceId")) + .ok_or_else(|| "stable-reference-missing".to_string())?; + let object_key = json_string_field(resource, "objectKey") + .or_else(|| json_string_field(generated, "objectKey")) + .ok_or_else(|| "stable-reference-missing".to_string())?; + let asset_object_id = json_string_field(resource, "assetObjectId") + .or_else(|| json_string_field(asset, "assetObjectId")) + .or_else(|| json_string_field(generated, "assetObjectId")) + .ok_or_else(|| "stable-reference-missing".to_string())?; + Ok(PrivateRemoteResult { + resource_id, + object_key, + asset_object_id, + }) +} + +fn committed_execution_from_private_result( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result { + let committed = ledger + .commit_result + .as_ref() + .ok_or_else(|| "已完成生成账本缺少本地 commit 回执".to_string())?; + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + let _asset = manifest + .assets + .iter() + .find(|asset| asset.id == committed.asset_id) + .ok_or_else(|| "已完成生成账本对应 manifest 资产不存在".to_string())?; + let generation = AssetCanvasGenerationRecord { + generation_id: ledger.generation_id.clone(), + intent_id: ledger.intent_id.clone(), + phase: AssetCanvasGenerationStatus::AssetDurableCommitted, + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + output_asset_id: Some(committed.asset_id.clone()), + error_code: None, + created_at: ledger.created_at, + updated_at: ledger.updated_at, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }; + Ok(GenerateAssetCanvasImageExecution { + result: GenerateAssetCanvasImageResult { + generation, + images: Vec::new(), + commit: AssetCanvasGenerationCommitResult { + resource_id: committed.resource_id.clone(), + asset_id: committed.asset_id.clone(), + project_id: committed.project_id.clone(), + commit_id: committed.commit_id.clone(), + committed_project_revision: committed.committed_project_revision, + draft_revision: committed.draft_revision, + host_revision: committed.host_revision.to_string(), + commit_status: committed.commit_status.clone(), + manifest, + event_id: committed.event_id.clone(), + }, + }, + event: None, + }) +} + +fn mark_generation_error( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(), String> { + set_private_phase( + root, + ledger, + if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }, + Some(error_code), + )?; + publish_public_phase(root, ledger, emit).map(|_| ()) +} + +fn sanitized_generation_error(code: &str) -> String { + match code { + "configuration-missing" => "图片生成配置缺失,未创建任何资源".to_string(), + "generation-rejected" => "图片生成请求被平台明确拒绝,未创建资源".to_string(), + "generation-failed" => "图片生成失败,未创建资源".to_string(), + "stable-reference-missing" => "远端生成结果缺少稳定资源引用,未创建本地资源".to_string(), + "reference-upload-failed" => "参考资源上传或确认失败,未提交生成".to_string(), + "download-reconciliation-required" => { + "reconciliation-required: 远端图片下载未完成,需要使用原 operation 恢复".to_string() + } + "commit-reconciliation-required" => { + "reconciliation-required: 图片已下载,但正式资产提交需要恢复或对账".to_string() + } + _ => "reconciliation-required: 图片生成结果未知,需要使用原幂等身份恢复,不能重新扣费" + .to_string(), + } +} + +fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool { + matches!( + ledger.phase, + GenerationLedgerPhase::Accepted | GenerationLedgerPhase::Running + ) || (ledger.phase == GenerationLedgerPhase::ReconciliationRequired + && ledger.operation_id.is_some() + && ledger.remote_result.is_none()) +} + +async fn reconcile_generation( + root: &Path, + mut ledger: AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { + return committed_execution_from_private_result(root, &ledger); + } + if ledger.phase == GenerationLedgerPhase::Failed { + return Err(sanitized_generation_error( + ledger.error_code.as_deref().unwrap_or("generation-failed"), + )); + } + let api_base_url = match resolve_canvas_sync_api_base_url(None) { + Ok(value) => value, + Err(_) => { + mark_generation_error(root, &mut ledger, false, "configuration-missing", emit)?; + return Err(sanitized_generation_error("configuration-missing")); + } + }; + let api_key = match resolve_canvas_sync_api_key(None) { + Ok(value) => value, + Err(_) => { + mark_generation_error(root, &mut ledger, false, "configuration-missing", emit)?; + return Err(sanitized_generation_error("configuration-missing")); + } + }; + let configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + if ledger + .external_configuration_fingerprint + .as_ref() + .is_some_and(|value| value != &configuration_fingerprint) + { + mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; + return Err(sanitized_generation_error("configuration-changed")); + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|_| "无法创建图片生成 HTTP 客户端".to_string())?; + let submit_client = reqwest::Client::builder() + .timeout(Duration::from_secs(35 * 60)) + .build() + .map_err(|_| "无法创建图片生成提交客户端".to_string())?; + + if matches!( + ledger.phase, + GenerationLedgerPhase::ContextPreparing + | GenerationLedgerPhase::ReferencesPreparing + | GenerationLedgerPhase::ReconciliationRequired + ) && ledger.request_body_json.is_none() + { + ledger.external_configuration_fingerprint = Some(configuration_fingerprint.clone()); + if ledger.canvas_context.is_none() { + let context = + prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) + .await + .map_err(|_| "准备 External Editor 画布上下文失败".to_string())?; + ledger.canvas_context = Some(context.into()); + write_generation_ledger(root, &mut ledger)?; + } + if let Err(_error) = + ensure_reference_states(root, &mut ledger, &client, &api_base_url, &api_key).await + { + mark_generation_error(root, &mut ledger, false, "reference-upload-failed", emit)?; + return Err(sanitized_generation_error("reference-upload-failed")); + } + let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; + ledger.endpoint = Some(endpoint); + ledger.request_body_sha256 = Some(asset_canvas_sha256(body_json.as_bytes())); + ledger.request_body_json = Some(body_json); + set_private_phase(root, &mut ledger, GenerationLedgerPhase::Prepared, None)?; + } + + let generated = if matches!( + ledger.phase, + GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired + ) && ledger.operation_id.is_none() + { + let endpoint = ledger + .endpoint + .as_deref() + .ok_or_else(|| "私有生成账本缺少 endpoint".to_string())?; + let request_body_json = ledger + .request_body_json + .as_deref() + .ok_or_else(|| "私有生成账本缺少 request body".to_string())?; + let response = match submit_external_generation_request( + &submit_client, + &api_base_url, + endpoint, + &api_key, + &ledger.idempotency_key, + request_body_json, + ) + .await + { + Ok(response) => response, + Err(_) => { + mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; + return Err(sanitized_generation_error("submit-result-unknown")); + } + }; + let status = response.status(); + if !status.is_success() { + if external_generation_submit_rejection_is_definitive(status) { + mark_generation_error(root, &mut ledger, false, "generation-rejected", emit)?; + return Err(sanitized_generation_error("generation-rejected")); + } + mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; + return Err(sanitized_generation_error("submit-result-unknown")); + } + let submission = match response.json::().await { + Ok(value) => value, + Err(_) => { + mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; + return Err(sanitized_generation_error("submit-result-unknown")); + } + }; + match classify_external_generation_initial_response(status, &submission) { + Ok(ExternalGenerationInitialResponse::LegacyCompleted(generated)) => { + set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; + publish_public_phase(root, &ledger, emit)?; + generated + } + Ok(ExternalGenerationInitialResponse::AsyncSubmission(submission)) => { + let operation_id = + json_string_field(external_editor_response_data(&submission), "operationId") + .ok_or_else(|| "平台接受响应缺少 operationId".to_string())?; + ledger.operation_id = Some(operation_id); + ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission)); + set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; + publish_public_phase(root, &ledger, emit)?; + set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; + publish_public_phase(root, &ledger, emit)?; + match wait_for_external_generation_result( + &client, + &api_base_url, + &api_key, + &submission, + ) + .await + { + Ok(result) => result, + Err(error) => { + let reconciliation = + platform_art_generation_error_needs_reconciliation(&error); + let code = if reconciliation { + "poll-result-unknown" + } else { + "generation-failed" + }; + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + return Err(sanitized_generation_error(code)); + } + } + } + Err(_) => { + mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; + return Err(sanitized_generation_error("submit-result-unknown")); + } + } + } else if should_poll_existing_operation(&ledger) { + let operation_id = ledger + .operation_id + .clone() + .ok_or_else(|| "已接受生成账本缺少 operationId".to_string())?; + let submission = serde_json::json!({ + "operationId": operation_id, + "pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000), + }); + set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; + publish_public_phase(root, &ledger, emit)?; + match wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await + { + Ok(result) => result, + Err(error) => { + let reconciliation = platform_art_generation_error_needs_reconciliation(&error); + let code = if reconciliation { + "poll-result-unknown" + } else { + "generation-failed" + }; + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + return Err(sanitized_generation_error(code)); + } + } + } else { + serde_json::Value::Null + }; + + if matches!( + ledger.phase, + GenerationLedgerPhase::Prepared + | GenerationLedgerPhase::Accepted + | GenerationLedgerPhase::Running + | GenerationLedgerPhase::ReconciliationRequired + ) && !generated.is_null() + { + let remote_result = match extract_remote_result(&generated) { + Ok(result) => result, + Err(_) => { + mark_generation_error(root, &mut ledger, false, "stable-reference-missing", emit)?; + return Err(sanitized_generation_error("stable-reference-missing")); + } + }; + ledger.remote_result = Some(remote_result); + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::RemoteCompleted, + None, + )?; + publish_public_phase(root, &ledger, emit)?; + } + + if matches!( + ledger.phase, + GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired + ) && ledger.commit_result.is_none() + { + let staged_exists = read_staged_image_locked(root, &ledger.staged_image_token) + .ok() + .is_some_and(|(metadata, _)| { + metadata.project_id == ledger.project_id + && metadata.draft_id == ledger.draft_id + && metadata.draft_revision == ledger.expected_draft_revision + }); + if !staged_exists { + let remote = ledger + .remote_result + .as_ref() + .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; + let download = match resolve_canvas_resource_download( + &client, + &api_base_url, + &api_key, + &serde_json::json!({ "objectKey": remote.object_key }), + ) + .await + { + Ok(Some(download)) => download, + _ => { + mark_generation_error( + root, + &mut ledger, + true, + "download-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error( + "download-reconciliation-required", + )); + } + }; + let staged = stage_asset_canvas_image_with_token_at( + root, + &StageAssetCanvasImageInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + draft_id: ledger.draft_id.clone(), + expected_draft_revision: ledger.expected_draft_revision, + media_type: download.media_type, + bytes: download.bytes, + }, + Some(&ledger.staged_image_token), + )?; + if staged.status != "staged" { + mark_generation_error( + root, + &mut ledger, + true, + "download-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error( + "download-reconciliation-required", + )); + } + } + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + )?; + publish_public_phase(root, &ledger, emit)?; + } + + if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + let commit = commit_asset_canvas_at( + root, + &CommitAssetCanvasInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: ledger.expected_draft_revision, + draft_id: ledger.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + intent: ledger.intent.clone(), + source_asset_id: ledger.source_asset_id.clone(), + staged_image_token: ledger.staged_image_token.clone(), + name: ledger.asset_name.clone(), + asset_kind: ledger.asset_kind.clone(), + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + generation_provenance: None, + }, + ); + let execution = match commit { + Ok(execution) => execution, + Err(_) => { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } + }; + let private_commit = match &execution.result { + CommitAssetCanvasResult::Committed { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "committed".to_string(), + event_id: event_id.clone(), + }, + CommitAssetCanvasResult::AlreadyCommitted { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "already-committed".to_string(), + event_id: event_id.clone(), + }, + _ => { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } + }; + ledger.commit_result = Some(private_commit); + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::AssetDurableCommitted, + None, + )?; + let generation = publish_public_phase(root, &ledger, emit)?; + let committed = ledger + .commit_result + .as_ref() + .expect("commit result was persisted"); + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + return Ok(GenerateAssetCanvasImageExecution { + result: GenerateAssetCanvasImageResult { + generation, + images: Vec::new(), + commit: AssetCanvasGenerationCommitResult { + resource_id: committed.resource_id.clone(), + asset_id: committed.asset_id.clone(), + project_id: committed.project_id.clone(), + commit_id: committed.commit_id.clone(), + committed_project_revision: committed.committed_project_revision, + draft_revision: committed.draft_revision, + host_revision: committed.host_revision.to_string(), + commit_status: committed.commit_status.clone(), + manifest, + event_id: committed.event_id.clone(), + }, + }, + event: execution.event, + }); + } + + committed_execution_from_private_result(root, &ledger) +} + +async fn generation_singleflight_lock( + project_id: &str, + generation_id: &str, +) -> tokio::sync::OwnedMutexGuard<()> { + let locks = + ASSET_CANVAS_GENERATION_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); + let key = format!("{project_id}:{generation_id}"); + let lock = { + let mut locks = locks.lock().await; + locks + .entry(key) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + lock.lock_owned().await +} + +pub(crate) async fn generate_asset_canvas_image_at( + root: &Path, + input: &GenerateAssetCanvasImageInput, + mut emit: impl FnMut(AssetCanvasGenerationProgressEvent) + Send, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let _guard = + generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; + let ledger = validate_and_prepare_ledger(root, input)?; + reconcile_generation(root, ledger, &mut emit).await +} + +pub(crate) async fn recover_asset_canvas_generations_at( + root: &Path, + input: &RecoverAssetCanvasGenerationsInput, + mut emit: impl FnMut(AssetCanvasGenerationProgressEvent) + Send, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/generations"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + }, + events: Vec::new(), + }) + } + Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), + }; + let mut generation_ids = Vec::new(); + for entry in entries.take(ASSET_CANVAS_MAX_GENERATIONS + 1) { + let entry = entry.map_err(|_| "读取素材画布私有生成账本失败".to_string())?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(generation_id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + validate_uuid_v4(generation_id, "generationId")?; + generation_ids.push(generation_id.to_string()); + } + if generation_ids.len() > ASSET_CANVAS_MAX_GENERATIONS { + return Err("素材画布私有生成账本数量超过上限".to_string()); + } + generation_ids.sort(); + let mut resumed = Vec::new(); + let mut events = Vec::new(); + for generation_id in generation_ids { + let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + continue; + }; + if ledger.project_id != input.expected_project_id + || ledger.draft_id != input.draft_id + || matches!( + ledger.phase, + GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed + ) + { + continue; + } + resumed.push(generation_id.clone()); + let _guard = generation_singleflight_lock(&ledger.project_id, &generation_id).await; + match reconcile_generation(root, ledger, &mut emit).await { + Ok(execution) => { + if let Some(event) = execution.event { + events.push(event); + } + } + Err(_) => {} + } + } + Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: resumed, + }, + events, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + use std::io::{Cursor, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + + fn test_png() -> Vec { + let image = RgbaImage::from_pixel(4, 3, Rgba([21, 87, 180, 255])); + let mut output = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image) + .write_to(&mut output, ImageFormat::Png) + .expect("encode generation PNG fixture"); + output.into_inner() + } + + fn read_http_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set generation fixture timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read generation request"); + assert!(read > 0, "generation request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read generation body"); + assert!(read > 0, "generation request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn write_json(stream: &mut TcpStream, status: &str, body: serde_json::Value) { + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write generation JSON response"); + } + + fn write_png(stream: &mut TcpStream, png: &[u8]) { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len(), + ) + .and_then(|_| stream.write_all(png)) + .expect("write generation PNG response"); + } + + fn create_generation_fixture( + project_id: &str, + project_name: &str, + ) -> (tempfile::TempDir, AssetCanvasDraft) { + let directory = tempfile::tempdir().expect("create generation fixture"); + init_local_game_project_at(directory.path(), project_id, project_name) + .expect("initialize generation project"); + let draft_id = Uuid::new_v4().to_string(); + let draft = create_asset_canvas_draft_at( + directory.path(), + &CreateAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id, + intent: AssetCanvasIntent::Create, + source_asset_id: None, + }, + ) + .expect("create generation draft") + .draft; + (directory, draft) + } + + fn generation_input( + root: &Path, + project_id: &str, + draft: &AssetCanvasDraft, + prompt: &str, + ) -> GenerateAssetCanvasImageInput { + GenerateAssetCanvasImageInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_host_revision: 0, + expected_draft_revision: draft.revision, + draft_id: draft.draft_id.clone(), + intent_id: Uuid::new_v4().to_string(), + generation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + commit_id: Uuid::new_v4().to_string(), + commit_idempotency_key: Uuid::new_v4().to_string(), + prompt: prompt.to_string(), + aspect_ratio: "16:9".to_string(), + image_size: "1K".to_string(), + asset_kind: "illustration".to_string(), + asset_name: "阶段五生成图".to_string(), + reference_resource_ids: Vec::new(), + } + } + + fn accepted_ledger( + project_id: &str, + draft: &AssetCanvasDraft, + base_url: &str, + api_key: &str, + ) -> AssetCanvasGenerationLedger { + let body_json = serde_json::json!({ + "prompt": "重启前已经提交的私有正文", + "projectId": "remote-project", + "assetFolderId": "remote-folder", + }) + .to_string(); + let now = asset_canvas_now(); + AssetCanvasGenerationLedger { + schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), + request_fingerprint: "a".repeat(64), + project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + intent: AssetCanvasIntent::Create, + source_asset_id: None, + source_resource_id: None, + intent_id: Uuid::new_v4().to_string(), + generation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + commit_id: Uuid::new_v4().to_string(), + commit_idempotency_key: Uuid::new_v4().to_string(), + expected_host_revision: 0, + expected_draft_revision: draft.revision, + prompt: "重启前已经提交的私有正文".to_string(), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "illustration".to_string(), + asset_name: "恢复生成图".to_string(), + requested_reference_resource_ids: Vec::new(), + reference_states: Vec::new(), + resolved_reference_ids: Vec::new(), + external_configuration_fingerprint: Some( + platform_art_generation_external_configuration_fingerprint(base_url, api_key), + ), + canvas_context: Some(PrivateCanvasContext { + project_id: "remote-project".to_string(), + asset_folder_id: "remote-folder".to_string(), + canvas_name: "恢复生成测试".to_string(), + }), + endpoint: Some("/api/external/v1/editor/images/generations".to_string()), + request_body_sha256: Some(asset_canvas_sha256(body_json.as_bytes())), + request_body_json: Some(body_json), + phase: GenerationLedgerPhase::Accepted, + operation_id: Some("accepted-operation-phase-five".to_string()), + poll_after_ms: Some(0), + remote_result: None, + staged_image_token: Uuid::new_v4().to_string(), + commit_result: None, + error_code: None, + created_at: now, + updated_at: now, + } + } + + #[tokio::test] + async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean() + { + let project_id = "phase-five-generation-project"; + let project_name = "阶段五生成测试项目"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind generation server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("generation address") + ); + let signed_url = format!("{base_url}/generated.png"); + let server_signed_url = signed_url.clone(); + let png = test_png(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..6 { + let (mut stream, _) = listener.accept().expect("accept generation request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture generation request"); + if request.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "phase-five-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request + .starts_with("GET /api/external/v1/generations/phase-five-operation ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "phase-five-operation", + "status": "completed", + "pollAfterMs": 0, + "result": { + "taskId": "external-task-must-stay-private", + "resource": { + "resourceId": "remote-resource", + "objectKey": "generated/result.png", + "assetObjectId": "remote-object", + } + } + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /generated.png ") { + write_png(&mut stream, &png); + } else { + panic!("unexpected generation request: {request}"); + } + } + }); + let api_key = "phase-five-secret-api-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), + ); + let private_prompt = "只允许进入私有账本的完整提示词正文"; + let input = generation_input(directory.path(), project_id, &draft, private_prompt); + let mut progress = Vec::new(); + let first = + generate_asset_canvas_image_at(directory.path(), &input, |event| progress.push(event)) + .await + .expect("generate and commit image"); + server.join().expect("join generation server"); + + assert_eq!( + first.result.generation.phase, + AssetCanvasGenerationStatus::AssetDurableCommitted + ); + assert!(first.result.commit.resource_id.starts_with("local-asset:")); + assert!(progress + .iter() + .any(|event| event.phase == "generation-accepted")); + assert!(progress + .iter() + .any(|event| event.phase == "generation-running")); + assert!(progress + .iter() + .any(|event| event.phase == "remote-completed")); + assert!(progress + .iter() + .any(|event| event.phase == "media-downloaded")); + assert!(progress + .iter() + .any(|event| event.phase == "asset-durable-committed")); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 6); + let submits = requests + .iter() + .filter(|request| { + request.starts_with("POST /api/external/v1/editor/images/generations ") + }) + .collect::>(); + assert_eq!(submits.len(), 1); + assert!(submits[0] + .to_ascii_lowercase() + .contains(&format!("idempotency-key: {}", input.idempotency_key))); + + let replay = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .expect("replay committed generation"); + assert_eq!(replay.result.commit, first.result.commit); + assert!(replay.event.is_none()); + + let mut conflicting = + generation_input(directory.path(), project_id, &draft, private_prompt); + conflicting.intent_id = input.intent_id.clone(); + conflicting.idempotency_key = input.idempotency_key.clone(); + let conflict = generate_asset_canvas_image_at(directory.path(), &conflicting, |_| {}) + .await + .err() + .expect("reject identity reuse under another generation"); + assert!(conflict.contains("不同 generationId")); + + let public_draft = read_asset_canvas_draft_at( + directory.path(), + &ReadAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + ) + .expect("read public generation draft") + .draft + .expect("public draft exists"); + let public_draft_json = + serde_json::to_string(&public_draft).expect("serialize public draft"); + let manifest_json = + serde_json::to_string(&first.result.commit.manifest).expect("serialize manifest"); + let progress_json = serde_json::to_string(&progress).expect("serialize progress"); + let public_event_json = serde_json::to_string(&asset_canvas_committed_public_event( + first.event.as_ref().expect("commit event"), + )) + .expect("serialize public event"); + for public in [ + public_draft_json.as_str(), + manifest_json.as_str(), + progress_json.as_str(), + public_event_json.as_str(), + ] { + assert!(!public.contains(private_prompt)); + assert!(!public.contains(api_key)); + assert!(!public.contains(&base_url)); + assert!(!public.contains(&input.idempotency_key)); + assert!(!public.contains(&input.commit_idempotency_key)); + assert!(!public.contains("phase-five-operation")); + assert!(!public.contains("external-task-must-stay-private")); + } + let private_ledger = fs::read_to_string( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("read private generation ledger"); + assert!(private_ledger.contains(private_prompt)); + assert!(private_ledger.contains(&input.idempotency_key)); + assert!(private_ledger.contains("phase-five-operation")); + assert!(!private_ledger.contains(api_key)); + assert!(!private_ledger.contains(&base_url)); + assert!(!private_ledger.contains(&signed_url)); + assert!(!private_ledger.contains("external-task-must-stay-private")); + let absolute_project_path = directory.path().to_string_lossy(); + assert!(!private_ledger.contains(absolute_project_path.as_ref())); + assert!(!public_draft_json.contains(absolute_project_path.as_ref())); + assert!(!manifest_json.contains(absolute_project_path.as_ref())); + assert!(!progress_json.contains(absolute_project_path.as_ref())); + } + + #[tokio::test] + async fn accepted_restart_uses_get_only_and_duplicate_recovery_does_not_redownload_or_recommit() + { + let project_id = "phase-five-recovery-project"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五 accepted 恢复测试"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("recovery address") + ); + let signed_url = format!("{base_url}/recovered.png"); + let png = test_png(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..3 { + let (mut stream, _) = listener.accept().expect("accept recovery request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture recovery request"); + if request + .starts_with("GET /api/external/v1/generations/accepted-operation-phase-five ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "accepted-operation-phase-five", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "recovered-resource", + "objectKey": "generated/recovered.png", + "assetObjectId": "recovered-object", + }} + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": signed_url}}), + ); + } else if request.starts_with("GET /recovered.png ") { + write_png(&mut stream, &png); + } else { + panic!("unexpected recovery request: {request}"); + } + } + }); + let api_key = "phase-five-recovery-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), + ); + let mut ledger = accepted_ledger(project_id, &draft, &base_url, api_key); + let generation_id = ledger.generation_id.clone(); + write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); + + let input = RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }; + let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) + .await + .expect("recover accepted generation"); + server.join().expect("join recovery server"); + assert_eq!(first.result.resumed_generation_ids, vec![generation_id]); + assert_eq!(first.events.len(), 1); + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 3); + assert!(requests.iter().all(|request| request.starts_with("GET "))); + + let second = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) + .await + .expect("ignore already committed generation"); + assert!(second.result.resumed_generation_ids.is_empty()); + assert!(second.events.is_empty()); + let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); + assert_eq!(manifest.assets.len(), 1); + } + + #[test] + fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { + let mut source_asset = GameCreationAppAssetManifestEntry { + id: "source".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: Some("local-asset:source".to_string()), + asset_object_id: Some("asset-object-source".to_string()), + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }; + assert_eq!(stable_manifest_reference(&source_asset), None); + source_asset.source.resource_id = Some("resource-source".to_string()); + assert_eq!( + stable_manifest_reference(&source_asset).as_deref(), + Some("resource-source") + ); + + let body_json = serde_json::json!({"prompt": "private"}).to_string(); + let source_resource_id = "local-asset:source".to_string(); + let now = asset_canvas_now(); + let ledger = AssetCanvasGenerationLedger { + schema_version: ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION.to_string(), + request_fingerprint: "b".repeat(64), + project_id: "refine-project".to_string(), + draft_id: Uuid::new_v4().to_string(), + intent: AssetCanvasIntent::Refine, + source_asset_id: Some("source".to_string()), + source_resource_id: Some(source_resource_id.clone()), + intent_id: Uuid::new_v4().to_string(), + generation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + commit_id: Uuid::new_v4().to_string(), + commit_idempotency_key: Uuid::new_v4().to_string(), + expected_host_revision: 0, + expected_draft_revision: 0, + prompt: "精修提示词".to_string(), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "illustration".to_string(), + asset_name: "精修结果".to_string(), + requested_reference_resource_ids: vec![ + source_resource_id.clone(), + "local-asset:style".to_string(), + ], + reference_states: vec![ + PrivateReferenceState { + resource_id: source_resource_id.clone(), + stable_reference: Some("objects/source.png".to_string()), + asset_object_id: Some("source-object".to_string()), + upload_ticket: None, + upload_completed: true, + }, + PrivateReferenceState { + resource_id: "local-asset:style".to_string(), + stable_reference: Some("objects/style.png".to_string()), + asset_object_id: Some("style-object".to_string()), + upload_ticket: None, + upload_completed: true, + }, + ], + resolved_reference_ids: vec![ + "objects/source.png".to_string(), + "objects/style.png".to_string(), + ], + external_configuration_fingerprint: Some("configuration".to_string()), + canvas_context: Some(PrivateCanvasContext { + project_id: "remote-project".to_string(), + asset_folder_id: "remote-folder".to_string(), + canvas_name: "精修画布".to_string(), + }), + endpoint: None, + request_body_sha256: Some(asset_canvas_sha256(body_json.as_bytes())), + request_body_json: Some(body_json), + phase: GenerationLedgerPhase::ReferencesPreparing, + operation_id: None, + poll_after_ms: None, + remote_result: None, + staged_image_token: Uuid::new_v4().to_string(), + commit_result: None, + error_code: None, + created_at: now, + updated_at: now, + }; + let (endpoint, request) = + build_generation_request_snapshot(&ledger).expect("build refine request"); + let request: serde_json::Value = + serde_json::from_str(&request).expect("parse refine request"); + assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); + assert_eq!(request["sourceImageSrc"], "objects/source.png"); + assert!(request.get("sourceResourceId").is_none()); + assert_eq!( + request["referenceImageSrcs"], + serde_json::json!(["objects/style.png"]) + ); + assert_eq!( + ledger.requested_reference_resource_ids, + vec![source_resource_id, "local-asset:style".to_string()] + ); + } + + #[test] + fn unknown_poll_result_keeps_the_original_operation_in_get_only_recovery() { + let project_id = "phase-five-poll-reconciliation"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五轮询对账测试"); + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + assert!(should_poll_existing_operation(&ledger)); + ledger.phase = GenerationLedgerPhase::Running; + assert!(should_poll_existing_operation(&ledger)); + ledger.phase = GenerationLedgerPhase::ReconciliationRequired; + ledger.error_code = Some("poll-result-unknown".to_string()); + assert!(should_poll_existing_operation(&ledger)); + ledger.remote_result = Some(PrivateRemoteResult { + resource_id: "remote-resource".to_string(), + object_key: "generated/result.png".to_string(), + asset_object_id: "remote-object".to_string(), + }); + assert!(!should_poll_existing_operation(&ledger)); + drop(directory); + } + + #[tokio::test] + async fn missing_configuration_fails_explicitly_without_creating_an_asset() { + let project_id = "phase-five-missing-config"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": {"baseUrl": "http://127.0.0.1:9", "apiKey": ""}}) + .to_string(), + ); + let prompt = "配置缺失时也不能公开的正文"; + let input = generation_input(directory.path(), project_id, &draft, prompt); + let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .err() + .expect("missing configuration must fail"); + assert!(error.contains("配置缺失")); + assert!(!error.contains(prompt)); + assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); + let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); + assert!(manifest.assets.is_empty()); + let public_draft = read_asset_canvas_draft_at( + directory.path(), + &ReadAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + ) + .expect("read failed generation draft") + .draft + .expect("failed generation draft exists"); + let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); + assert!(!public.contains(prompt)); + assert!(!public.contains(&input.idempotency_key)); + } +} diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 8c51dbc0f..a8e297679 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -9,6 +9,8 @@ import { fitViewportToLayers, type ImageCanvasDraft, type ImageCanvasDraftCanvas, + type ImageCanvasGenerationProgress, + type ImageCanvasGenerationProgressPhase, type ImageCanvasHostScope, type ImageCanvasMediaRef, MAX_HISTORY_STEPS, @@ -50,6 +52,10 @@ export type AssetCanvasLifecycleState = stage: 'draft' | 'staging' | 'committing' | 'projecting'; } | { kind: 'canvas.recovering' } + | { + kind: 'canvas.generating'; + phase: ImageCanvasGenerationProgressPhase; + } | { kind: 'canvas.failed'; code: string; @@ -85,6 +91,15 @@ type HistorySnapshot = { selectedLayerIds: string[]; }; +type PendingGenerationIdentity = { + saveAttemptId: string; + intentId: string; + generationId: string; + idempotencyKey: string; + commitId: string; + commitIdempotencyKey: string; +}; + type DragState = | { kind: 'pan'; @@ -278,6 +293,18 @@ export function AssetCanvasSurface({ const [exportMediaType, setExportMediaType] = useState< 'image/png' | 'image/jpeg' | 'image/webp' >('image/png'); + const [generationDialog, setGenerationDialog] = useState< + 'edit' | 'confirm' | null + >(null); + const [generationPrompt, setGenerationPrompt] = useState(''); + const [generationAspectRatio, setGenerationAspectRatio] = useState< + '1:1' | '2:3' | '3:2' | '9:16' | '16:9' + >('1:1'); + const [generationImageSize, setGenerationImageSize] = useState< + '0.5K' | '1K' | '2K' + >('1K'); + const [generationReferenceResourceIds, setGenerationReferenceResourceIds] = + useState([]); const [documentVersion, setDocumentVersion] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const viewportElementRef = useRef(null); @@ -302,6 +329,9 @@ export function AssetCanvasSurface({ idempotencyKey: string; documentVersion: number; } | null>(null); + const pendingGenerationRef = useRef(null); + const generationStartingRef = useRef(false); + const generationFocusEpochRef = useRef(0); layersRef.current = layers; viewportRef.current = viewport; @@ -430,6 +460,9 @@ export function AssetCanvasSurface({ saveQueueRef.current = Promise.resolve(); savePromiseRef.current = null; pendingCommitRef.current = null; + pendingGenerationRef.current = null; + generationStartingRef.current = false; + generationFocusEpochRef.current += 1; hostRevisionRef.current = expectedHostRevision; deliveredEventsRef.current.clear(); setLifecycle({ kind: 'canvas.recovering' }); @@ -504,6 +537,33 @@ export function AssetCanvasSurface({ ); } await hydrateDraft(nextDraft, epoch); + if (epoch !== epochRef.current) return; + const recoveryFocusEpoch = generationFocusEpochRef.current; + void host.generation + .recoverImages({ + scope: stableScope, + onProgress: (progress) => { + if ( + epoch === epochRef.current && + recoveryFocusEpoch === generationFocusEpochRef.current + ) { + setNotice(`正在恢复图片生成:${progress.phase}`); + } + }, + }) + .then((result) => { + if ( + epoch !== epochRef.current || + recoveryFocusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (result.status === 'ok' && result.value.resumedGenerationIds.length) { + setNotice( + `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, + ); + } + }); })().catch((error: unknown) => { if ( epoch === epochRef.current && @@ -521,6 +581,7 @@ export function AssetCanvasSurface({ }); return () => { epochRef.current += 1; + generationFocusEpochRef.current += 1; unlisten?.(); for (const url of previewUrls) URL.revokeObjectURL(url); previewUrls.clear(); @@ -966,32 +1027,219 @@ export function AssetCanvasSurface({ }); }, [host.project, onCancel, stableScope]); - const runMockGeneration = useCallback(() => { + const openGenerationDialog = useCallback(() => { const currentDraft = draftRef.current; - if (!currentDraft) return; + if ( + !currentDraft || + lifecycleRef.current.kind === 'canvas.saving' || + lifecycleRef.current.kind === 'canvas.generating' + ) { + return; + } + const selectedReferences = layersRef.current + .filter((layer) => selectionRef.current.includes(layer.id)) + .map((layer) => layer.resourceId); + if ( + stableScope.intent === 'refine' && + currentDraft.sourceResourceId && + !selectedReferences.includes(currentDraft.sourceResourceId) + ) { + selectedReferences.push(currentDraft.sourceResourceId); + } + pendingGenerationRef.current = { + saveAttemptId: crypto.randomUUID(), + intentId: crypto.randomUUID(), + generationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + commitId: crypto.randomUUID(), + commitIdempotencyKey: crypto.randomUUID(), + }; + setGenerationReferenceResourceIds([...new Set(selectedReferences)]); + setGenerationDialog('edit'); + setNotice(''); + }, [stableScope.intent]); + + const closeGenerationDialog = useCallback(() => { + if (generationStartingRef.current) return; + pendingGenerationRef.current = null; + setGenerationDialog(null); + }, []); + + const showGenerationConfirmation = useCallback(() => { + if (!generationPrompt.trim()) { + setNotice('请先填写图片提示词'); + return; + } + setGenerationDialog('confirm'); + }, [generationPrompt]); + + const confirmGeneration = useCallback(() => { + if (generationStartingRef.current) return; + const identity = pendingGenerationRef.current; + const initialDraft = draftRef.current; + const prompt = generationPrompt.trim(); + if (!identity || !initialDraft || !prompt) return; + generationStartingRef.current = true; const epoch = epochRef.current; - void host.generation - .generateImage({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - generationId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), - prompt: 'mock', - referenceResourceIds: [], - }) - .then((result) => { - if (epoch !== epochRef.current) return; - setNotice( - result.status === 'unsupported-capability' - ? result.message - : 'Mock 生成已返回', - ); - }) - .catch((error: unknown) => { - if (epoch !== epochRef.current) return; - setNotice(error instanceof Error ? error.message : String(error)); + const focusEpoch = generationFocusEpochRef.current + 1; + generationFocusEpochRef.current = focusEpoch; + const frozenReferences = [...generationReferenceResourceIds]; + const frozenAspectRatio = generationAspectRatio; + const frozenImageSize = generationImageSize; + const frozenAssetKind = assetKind; + const frozenAssetName = assetName; + const needsDraftPersist = + lifecycleRef.current.kind === 'canvas.editing' && + lifecycleRef.current.dirty; + setGenerationDialog(null); + setLifecycle({ + kind: 'canvas.generating', + phase: 'confirmation-required', + }); + const task = (async () => { + if (needsDraftPersist) { + const persisted = await persistDraft(); + if (!persisted) return; + } + const currentDraft = draftRef.current; + if ( + !currentDraft || + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + onSaveAttempt?.({ + saveAttemptId: identity.saveAttemptId, + sessionId, + projectId: stableScope.projectId, + draftId: stableScope.draftId, + commitId: identity.commitId, }); - }, [host.generation, stableScope]); + const onProgress = (progress: ImageCanvasGenerationProgress) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current && + progress.intentId === identity.intentId && + progress.generationId === identity.generationId + ) { + setLifecycle({ kind: 'canvas.generating', phase: progress.phase }); + setNotice(progress.errorCode ?? ''); + } + }; + const result = await host.generation.generateImage({ + scope: stableScope, + expectedHostRevision: hostRevisionRef.current, + expectedDraftRevision: currentDraft.revision, + intentId: identity.intentId, + generationId: identity.generationId, + idempotencyKey: identity.idempotencyKey, + commitId: identity.commitId, + commitIdempotencyKey: identity.commitIdempotencyKey, + prompt, + aspectRatio: frozenAspectRatio, + imageSize: frozenImageSize, + assetKind: frozenAssetKind, + assetName: frozenAssetName, + referenceResourceIds: frozenReferences, + onProgress, + }); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '图片生成发生 revision 冲突', + reconciliationRequired: + result.status === 'failed' && + result.code === 'reconciliation-required', + }); + return; + } + const { commit } = result.value; + hostRevisionRef.current = commit.hostRevision; + if (draftRef.current) { + const nextDraft = { + ...draftRef.current, + revision: commit.draftRevision, + status: 'committed' as const, + generations: [ + ...draftRef.current.generations.filter( + (record) => record.generationId !== identity.generationId, + ), + result.value.generation, + ], + }; + draftRef.current = nextDraft; + setDraft(nextDraft); + } + if (!deliveredEventsRef.current.has(commit.eventId)) { + deliveredEventsRef.current.add(commit.eventId); + onCommitted?.({ + source: 'command', + projectPath: host.projectPath, + projectId: commit.projectId, + draftId: stableScope.draftId, + commitId: commit.commitId, + assetId: commit.assetId, + manifest: commit.manifest as GameCreationAppManifest, + projectRevision: Number(commit.hostRevision), + committedProjectRevision: commit.committedProjectRevision, + eventId: commit.eventId, + }); + } + pendingGenerationRef.current = null; + setLifecycle({ kind: 'canvas.editing', dirty: false }); + setNotice('AI 图片已正式提交并进入资源总览'); + })().catch((error: unknown) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current + ) { + setLifecycle({ + kind: 'canvas.failed', + code: 'canvas-generation-failed', + message: error instanceof Error ? error.message : String(error), + reconciliationRequired: false, + }); + } + }); + void task.finally(() => { + if (generationFocusEpochRef.current === focusEpoch) { + generationStartingRef.current = false; + } + }); + }, [ + assetKind, + assetName, + generationAspectRatio, + generationImageSize, + generationPrompt, + generationReferenceResourceIds, + host.generation, + host.projectPath, + onCommitted, + onSaveAttempt, + persistDraft, + sessionId, + stableScope, + ]); + + const stopWaitingForGeneration = useCallback(() => { + generationFocusEpochRef.current += 1; + pendingGenerationRef.current = null; + setGenerationDialog(null); + setNotice('生成仍会在后台使用原 operation 安全对账'); + onCancel?.(); + }, [onCancel]); const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), @@ -1028,10 +1276,16 @@ export function AssetCanvasSurface({
- - - setAssetName(event.target.value)} + disabled={lifecycle.kind === 'canvas.generating'} /> setAssetKind(event.target.value)} + disabled={lifecycle.kind === 'canvas.generating'} />