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 2831f11fb..31a97c103 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 @@ -29,7 +29,7 @@ pub(in crate::agent) use canvas_generation::{ 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(crate) use external_generation_state::platform_art_generation_external_service_fingerprint; pub(in crate::agent) use external_generation_state::{ game_creator_agent_runtime_external_generation_exists, platform_art_generation_runtime_context_from_pending, @@ -39,6 +39,7 @@ pub(in crate::agent) use external_generation_state::{ }; #[cfg(test)] pub(crate) use external_generation_state::{ + platform_art_generation_external_configuration_fingerprint, setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, write_platform_art_generation_runtime_prepared_for_test, 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 946cf06b0..6ffcd6ae4 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 @@ -1,13 +1,16 @@ +#[cfg(test)] +use super::external_generation_state::platform_art_generation_external_configuration_fingerprint; use super::external_generation_state::{ mark_platform_art_generation_runtime_accepted, mark_platform_art_generation_runtime_legacy_completed, - platform_art_generation_external_configuration_fingerprint, + migrate_platform_art_generation_external_configuration, + platform_art_generation_external_service_fingerprint, platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result, platform_art_generation_runtime_request_body_json, platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, platform_art_generation_runtime_submission_payload, prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state, - validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState, + PlatformArtGenerationRuntimeState, }; use super::*; @@ -1155,12 +1158,19 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let api_base_url = resolve_canvas_sync_api_base_url(None)?; let api_key = resolve_canvas_sync_api_key(None)?; - if let Some(state) = persisted_runtime_state.as_ref() { - validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; - } + let persisted_runtime_state = persisted_runtime_state + .map(|state| { + migrate_platform_art_generation_external_configuration( + root, + state, + &api_base_url, + &api_key, + ) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")) + }) + .transpose()?; let external_configuration_fingerprint = - platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + platform_art_generation_external_service_fingerprint(&api_base_url); let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) .build() @@ -6512,7 +6522,7 @@ mod canvas_generation_tests { } #[tokio::test] - async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() { + async fn accepted_runtime_generation_rejects_external_service_drift_before_get() { let temporary = tempfile::tempdir().expect("create configuration drift project"); let root = temporary.path(); init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移") @@ -6552,10 +6562,7 @@ mod canvas_generation_tests { action_fingerprint: "configuration-drift-fingerprint".to_string(), }; let stale_configuration_fingerprint = - platform_art_generation_external_configuration_fingerprint( - "https://old-editor.example.test", - "old-editor-key", - ); + platform_art_generation_external_service_fingerprint("https://old-editor.example.test"); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -6587,7 +6594,7 @@ mod canvas_generation_tests { Err(error) => error, Ok(_) => panic!("configuration drift must block GET-only recovery"), }; - assert!(error.contains("baseUrl/API Key"), "{error}"); + assert!(error.contains("服务地址身份"), "{error}"); assert!(matches!( listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock 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 247b78fa0..5c4a81386 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,12 @@ fn request_body_json_and_sha256( Ok((request_body_json, request_body_sha256)) } -pub(crate) fn platform_art_generation_external_configuration_fingerprint( +pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String { + let normalized_base_url = api_base_url.trim().trim_end_matches('/'); + format!("{:x}", Sha256::digest(normalized_base_url.as_bytes())) +} + +fn platform_art_generation_legacy_external_configuration_fingerprint( api_base_url: &str, api_key: &str, ) -> String { @@ -134,18 +139,48 @@ pub(crate) fn platform_art_generation_external_configuration_fingerprint( ) } +// Keep the historical helper for persisted-agent call sites while changing its +// identity semantics: credentials authorize an operation but do not own it. +#[cfg(test)] +pub(crate) fn platform_art_generation_external_configuration_fingerprint( + api_base_url: &str, + _api_key: &str, +) -> String { + platform_art_generation_external_service_fingerprint(api_base_url) +} + pub(super) fn validate_platform_art_generation_external_configuration( state: &PlatformArtGenerationRuntimeState, api_base_url: &str, api_key: &str, ) -> Result<(), String> { - let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key); - if state.external_configuration_fingerprint != current { - return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string()); + let current = platform_art_generation_external_service_fingerprint(api_base_url); + let legacy = + platform_art_generation_legacy_external_configuration_fingerprint(api_base_url, api_key); + if state.external_configuration_fingerprint != current + && state.external_configuration_fingerprint != legacy + { + return Err("External Editor 生成账本与当前服务地址身份不一致".to_string()); } Ok(()) } +pub(super) fn migrate_platform_art_generation_external_configuration( + root: &Path, + mut state: PlatformArtGenerationRuntimeState, + api_base_url: &str, + api_key: &str, +) -> Result { + validate_platform_art_generation_external_configuration(&state, api_base_url, api_key)?; + let current = platform_art_generation_external_service_fingerprint(api_base_url); + if state.external_configuration_fingerprint != current { + state.external_configuration_fingerprint = current; + state.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &state)?; + } + Ok(state) +} + fn validate_platform_art_generation_runtime_identity( root: &Path, state: &PlatformArtGenerationRuntimeState, @@ -667,9 +702,8 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( let context = platform_art_generation_runtime_context_from_pending(pending); let api_base_url = resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); - let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); let external_configuration_fingerprint = - platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + platform_art_generation_external_service_fingerprint(&api_base_url); let (state, created) = prepare_platform_art_generation_runtime_state( root, &context, @@ -700,9 +734,8 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test( let context = platform_art_generation_runtime_context_from_pending(pending); let api_base_url = resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); - let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); let external_configuration_fingerprint = - platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + platform_art_generation_external_service_fingerprint(&api_base_url); let (_, created) = prepare_platform_art_generation_runtime_state( root, &context, @@ -786,6 +819,85 @@ pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( mod external_generation_state_tests { use super::*; + #[test] + fn external_service_fingerprint_ignores_key_rotation_and_normalizes_trailing_slash() { + let original = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test/", + "original-key", + ); + let rotated = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test", + "rotated-key", + ); + let different_service = platform_art_generation_external_service_fingerprint( + "https://other-editor.example.test", + ); + + assert_eq!(original, rotated); + assert_ne!(original, different_service); + } + + #[test] + fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() { + let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-"); + let root = temporary.path(); + init_local_game_project_at(root, "legacy-fingerprint", "旧配置指纹迁移") + .expect("init project"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let api_base_url = "https://editor.example.test/"; + let legacy_fingerprint = platform_art_generation_legacy_external_configuration_fingerprint( + api_base_url, + "original-key", + ); + let (legacy_state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-fingerprint-canvas", + "恢复旧请求", + &serde_json::json!({ + "prompt": "恢复旧请求", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }), + &legacy_fingerprint, + ) + .expect("prepare legacy generation ledger"); + assert!(created); + + let migrated = migrate_platform_art_generation_external_configuration( + root, + legacy_state, + api_base_url, + "original-key", + ) + .expect("migrate legacy fingerprint"); + assert_eq!( + migrated.external_configuration_fingerprint, + platform_art_generation_external_service_fingerprint(api_base_url) + ); + validate_platform_art_generation_external_configuration( + &migrated, + "https://editor.example.test", + "rotated-key", + ) + .expect("rotated key must recover the migrated operation"); + assert!(validate_platform_art_generation_external_configuration( + &migrated, + "https://other-editor.example.test", + "rotated-key", + ) + .is_err()); + + let persisted = read_platform_art_generation_runtime_state(root, &context) + .expect("read migrated ledger") + .expect("migrated ledger exists"); + assert_eq!(persisted, migrated); + } + fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction { let mut runtime = start_game_creator_agent_runtime_task_at( root, @@ -915,12 +1027,12 @@ mod external_generation_state_tests { "test-api-key", ) .is_err()); - assert!(validate_platform_art_generation_external_configuration( + validate_platform_art_generation_external_configuration( &prepared, "https://editor.example.test", "different-api-key", ) - .is_err()); + .expect("rotated API Key must keep ownership of the accepted service operation"); assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared recovery"), 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 943a363cd..466a6322f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1222,6 +1222,24 @@ pub(crate) async fn derive_local_project_resource( derive_local_project_resource_at(input).await } +#[tauri::command] +pub(crate) fn list_pending_local_project_resource_edits( + input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.list")?; + list_pending_local_project_resource_edits_at(input) +} + +#[tauri::command] +pub(crate) async fn resume_local_project_resource_edit( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + resume_local_project_resource_edit_at(input).await +} + #[tauri::command] pub(crate) fn normalize_local_project_raster_resource( input: NormalizeLocalProjectRasterResourceInput, 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 4f8e996c4..03da2dba4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2207,6 +2207,8 @@ fn main() { upload_local_asset, register_local_asset, derive_local_project_resource, + list_pending_local_project_resource_edits, + resume_local_project_resource_edit, normalize_local_project_raster_resource, import_canvas_asset, import_canvas_export, 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 9506ffdc7..406166c3e 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 @@ -1273,7 +1273,9 @@ pub(crate) fn update_asset_canvas_draft_at( validate_safe_revision(draft.revision, "草稿 revision")?; draft.status = input.status.clone(); draft.canvas = input.canvas.clone(); - draft.generations = input.generations.clone(); + // Generation records are advanced by the Rust generation ledger. A regular + // canvas autosave may carry an older frontend snapshot and must never erase + // accepted/running/reconciliation facts written by the backend. draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; Ok(UpdateAssetCanvasDraftResult { 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 index 8679d13ce..7a6450d01 100644 --- 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 @@ -304,7 +304,8 @@ fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), St } fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { - platform_art_generation_external_configuration_fingerprint(api_base_url, mode.bearer_token()) + let _ = mode; + platform_art_generation_external_service_fingerprint(api_base_url) } fn authorize_canvas_request( @@ -842,6 +843,7 @@ fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Optio !value.is_empty() && !value.starts_with("local-asset:") && !value.starts_with("draft-media:") + && !value.starts_with("task:") }) .map(str::to_string) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index f8b4d0f23..9bce10dce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -938,3 +938,44 @@ fn rejects_bad_signature_oversize_identity_replacement_and_linked_sidecars() { ); } } + +#[test] +fn ordinary_draft_update_preserves_backend_authoritative_generation_records() { + let fixture = initialize_fixture(); + let mut authoritative = fixture.draft.clone(); + authoritative.generations.push(AssetCanvasGenerationRecord { + generation_id: Uuid::new_v4().to_string(), + intent_id: Uuid::new_v4().to_string(), + phase: AssetCanvasGenerationStatus::GenerationRunning, + reference_resource_ids: Vec::new(), + output_asset_id: None, + error_code: None, + created_at: 1, + updated_at: 2, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }); + write_asset_canvas_draft_locked(fixture.root(), &authoritative) + .expect("write authoritative generation record"); + + let mut canvas = authoritative.canvas.clone(); + canvas.viewport.x = 42.0; + let updated = update_asset_canvas_draft_at( + fixture.root(), + &UpdateAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: authoritative.draft_id.clone(), + expected_draft_revision: authoritative.revision, + status: AssetCanvasDraftStatus::Editing, + canvas, + generations: Vec::new(), + }, + ) + .expect("save stale frontend draft"); + assert_eq!(updated.draft.generations, authoritative.generations); + assert_eq!(updated.draft.canvas.viewport.x, 42.0); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 5b74eb5e5..7424b46e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -14,6 +14,9 @@ const RESOURCE_EDIT_ROOT: &str = ".agent/resource-edits"; const RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; const RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; const RESOURCE_EDIT_UPLOAD_NAMESPACE: &str = "resource-editor-references"; +const RESOURCE_EDIT_LEDGER_SCAN_MAX_ENTRIES: usize = 4_096; +const RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION: &str = + "game-creator-resource-edit-version-transaction.v1"; static RESOURCE_EDIT_LOCK: OnceLock> = OnceLock::new(); @@ -94,6 +97,32 @@ pub(crate) struct DeriveLocalProjectResourceResult { pub(crate) manifest: GameCreationAppManifest, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ListPendingLocalProjectResourceEditsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingLocalProjectResourceEdit { + pub(crate) operation_id: String, + pub(crate) edit_kind: LocalProjectResourceEditKind, + pub(crate) source_resource_id: String, + pub(crate) asset_name: String, + pub(crate) phase: String, + pub(crate) created_at: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ResumeLocalProjectResourceEditInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) operation_id: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct NormalizeLocalProjectRasterResourceInput { @@ -140,6 +169,19 @@ enum ResourceEditLedgerPhase { ReconciliationRequired, } +impl ResourceEditLedgerPhase { + fn as_str(&self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::Accepted => "accepted", + Self::RemoteCompleted => "remote-completed", + Self::MediaDownloaded => "media-downloaded", + Self::Committed => "committed", + Self::ReconciliationRequired => "reconciliation-required", + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct ResourceEditLedger { @@ -151,7 +193,17 @@ struct ResourceEditLedger { project_id: String, expected_project_revision: u64, source_resource_id: String, + #[serde(default)] + source_asset_id: Option, source_path: Option, + #[serde(default)] + source_media_type: Option, + #[serde(default)] + source_asset_kind: Option, + #[serde(default)] + producer_task_id: Option, + #[serde(default)] + source_version_id: Option, source_sha256: String, prompt: String, asset_name: String, @@ -174,6 +226,30 @@ struct ResourceEditLedger { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ResourceEditVersionJournalPhase { + Prepared, + ManifestWritten, + RevisionWritten, + Committed, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ResourceEditVersionJournal { + schema_version: String, + operation_id: String, + project_id: String, + source_version_id: String, + base_project_revision: u64, + target_project_revision: u64, + version: shared_contracts::game_creation_app::GameIterationVersion, + phase: ResourceEditVersionJournalPhase, + created_at: u64, + updated_at: u64, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ResourceEditTextEnvelope { @@ -197,6 +273,102 @@ fn resource_edit_staging_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/staging/{operation_id}.bin") } +fn resource_edit_version_journal_path(operation_id: &str) -> String { + format!("{RESOURCE_EDIT_ROOT}/version-transactions/{operation_id}.json") +} + +fn read_resource_edit_version_journal( + root: &Path, + operation_id: &str, +) -> Result, String> { + read_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_version_journal_path(operation_id), + "资源编辑版本事务日志", + RESOURCE_EDIT_LEDGER_MAX_BYTES, + ) +} + +fn write_resource_edit_version_journal( + root: &Path, + journal: &ResourceEditVersionJournal, +) -> Result<(), String> { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_version_journal_path(&journal.operation_id), + "资源编辑版本事务日志", + journal, + RESOURCE_EDIT_LEDGER_MAX_BYTES, + ) +} + +fn update_resource_edit_version_journal_phase( + root: &Path, + journal: &mut ResourceEditVersionJournal, + phase: ResourceEditVersionJournalPhase, +) -> Result<(), String> { + journal.phase = phase; + journal.updated_at = unix_timestamp(); + write_resource_edit_version_journal(root, journal) +} + +fn stable_resource_edit_object_key(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && !value.starts_with(['/', '\\']) + && !value.starts_with("http://") + && !value.starts_with("https://") + && !value.contains(['?', '#', '\\']) + && !value.chars().any(char::is_control) + && !value.split('/').any(|segment| segment == "..")) + .then(|| value.to_string()) +} + +fn committed_resource_edit_object_key_for_asset( + root: &Path, + asset_id: &str, +) -> Result, String> { + let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("读取资源编辑账本目录失败:{error}")), + }; + let mut inspected = 0_usize; + for entry in entries { + let entry = entry.map_err(|error| format!("读取资源编辑账本失败:{error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("读取资源编辑账本类型失败:{error}"))?; + if !file_type.is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("json") + { + continue; + } + inspected += 1; + if inspected > 4_096 { + return Err("资源编辑账本数量超过安全扫描上限".to_string()); + } + let entry_path = entry.path(); + let Some(operation_id) = entry_path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + let Some(ledger) = read_resource_edit_ledger(root, operation_id)? else { + continue; + }; + if ledger.schema_version == RESOURCE_EDIT_SCHEMA_VERSION + && ledger.phase == ResourceEditLedgerPhase::Committed + && ledger.result_asset_id.as_deref() == Some(asset_id) + { + return Ok(ledger + .remote_object_key + .as_deref() + .and_then(stable_resource_edit_object_key)); + } + } + Ok(None) +} + fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { let parsed = Uuid::parse_str(value).map_err(|_| format!("{label} 必须是 UUID v4"))?; if parsed.get_version_num() != 4 || parsed.hyphenated().to_string() != value { @@ -263,7 +435,6 @@ fn resource_edit_request_fingerprint( let payload = serde_json::to_vec(&serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, - "expectedProjectRevision": input.expected_project_revision, "operationId": input.operation_id, "editKind": input.edit_kind, "sourceResourceId": source.canonical_resource_id, @@ -276,6 +447,28 @@ fn resource_edit_request_fingerprint( Ok(sha256_hex(&payload)) } +fn legacy_resource_edit_request_fingerprint( + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + prompt: &str, + asset_name: &str, +) -> Result { + let payload = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, + "projectId": input.expected_project_id, + "expectedProjectRevision": input.expected_project_revision, + "operationId": input.operation_id, + "editKind": input.edit_kind, + "sourceResourceId": source.canonical_resource_id, + "sourcePath": source.source_path, + "sourceSha256": source.source_sha256, + "prompt": prompt, + "assetName": asset_name, + })) + .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; + Ok(sha256_hex(&payload)) +} + fn read_resource_edit_ledger( root: &Path, operation_id: &str, @@ -377,6 +570,66 @@ fn resource_edit_audio_kind(asset_kind: &str, path: &str) -> LocalProjectResourc } } +fn infer_resource_edit_source_media_type( + edit_kind: &LocalProjectResourceEditKind, + source_path: Option<&str>, +) -> Option { + let extension = source_path + .and_then(|path| Path::new(path).extension()) + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase); + let media_type = match (edit_kind, extension.as_deref()) { + (LocalProjectResourceEditKind::ImageReference, Some("png")) => "image/png", + (LocalProjectResourceEditKind::ImageReference, Some("jpg" | "jpeg")) => "image/jpeg", + (LocalProjectResourceEditKind::ImageReference, Some("webp")) => "image/webp", + (LocalProjectResourceEditKind::Svg, Some("svg")) => "image/svg+xml", + (LocalProjectResourceEditKind::Video, Some("mp4")) => "video/mp4", + (LocalProjectResourceEditKind::Video, Some("webm")) => "video/webm", + (LocalProjectResourceEditKind::Video, Some("mov")) => "video/quicktime", + ( + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic, + Some("wav"), + ) => "audio/wav", + ( + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic, + Some("mp3"), + ) => "audio/mpeg", + ( + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic, + Some("ogg"), + ) => "audio/ogg", + ( + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic, + Some("flac"), + ) => "audio/flac", + ( + LocalProjectResourceEditKind::SoundEffect + | LocalProjectResourceEditKind::BackgroundMusic, + Some("m4a" | "mp4"), + ) => "audio/mp4", + _ => return None, + }; + Some(media_type.to_string()) +} + +fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { + match edit_kind { + LocalProjectResourceEditKind::ImageReference => "art-image", + LocalProjectResourceEditKind::Svg => "svg", + LocalProjectResourceEditKind::Video => "video", + LocalProjectResourceEditKind::SoundEffect => "sound-effect", + LocalProjectResourceEditKind::BackgroundMusic => "background-music", + LocalProjectResourceEditKind::Text => "text", + LocalProjectResourceEditKind::AgentResult => "agent-result-derivative", + LocalProjectResourceEditKind::Version => "project-version", + } + .to_string() +} + fn resolve_agent_result_source( root: &Path, source_resource_id: &str, @@ -968,6 +1221,11 @@ async fn ensure_resource_edit_source_reference( return Ok(reference); } if let Some(asset) = source.source_asset.as_ref() { + if let Some(reference) = committed_resource_edit_object_key_for_asset(root, &asset.id)? { + ledger.source_stable_reference = Some(reference.clone()); + write_resource_edit_ledger(root, ledger)?; + return Ok(reference); + } if let Some(reference) = asset .source .resource_id @@ -977,6 +1235,7 @@ async fn ensure_resource_edit_source_reference( !value.is_empty() && !value.starts_with("local-asset:") && !value.starts_with("draft-media:") + && !value.starts_with("task:") }) .map(str::to_string) .or_else(|| asset.source.asset_object_id.clone()) @@ -1355,14 +1614,14 @@ async fn prepare_remote_resource_edit( let api_key = resolve_canvas_sync_api_key(None) .map_err(|_| "External Editor API Key 配置缺失".to_string())?; let api_identity_fingerprint = - platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + platform_art_generation_external_service_fingerprint(&api_base_url); if ledger .api_identity_fingerprint .as_ref() .is_some_and(|value| value != &api_identity_fingerprint) { return Err( - "result-unknown: External Editor 配置已变更,必须保留原 operation 对账".to_string(), + "result-unknown: External Editor 服务地址已变更,必须保留原 operation 对账".to_string(), ); } if ledger @@ -1615,14 +1874,14 @@ pub(crate) fn normalize_local_project_raster_resource_at( .filter(|value| !value.is_empty() && *value != "task-artifact") .unwrap_or("art-image"); let asset = GameCreationAppAssetManifestEntry { - id: asset_id, + id: asset_id.clone(), kind: source_subtype.to_string(), media_type: verified_media_type, local_path: source_path, source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Generated, canvas_project_id: None, - resource_id: Some(source_resource_id.to_string()), + resource_id: Some(format!("local-asset:{asset_id}")), asset_object_id: None, task_id: Some(producer_task_id.to_string()), prompt: None, @@ -1679,7 +1938,6 @@ fn commit_resource_edit_asset( staged_extension ); let _project_lock = acquire_project_write_lock(root, "resource.edit")?; - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -1695,9 +1953,6 @@ fn commit_resource_edit_asset( None, ); } - if current_revision.revision != input.expected_project_revision { - return Err("project-revision-conflict".to_string()); - } let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; if fresh_source.canonical_resource_id != source.canonical_resource_id || fresh_source.source_sha256 != source.source_sha256 @@ -1815,15 +2070,16 @@ fn commit_resource_edit_version( ) -> Result { let version_id = format!("edit-{}", input.operation_id); let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?; - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } + let existing_journal = read_resource_edit_version_journal(root, &input.operation_id)?; if let Some(existing) = manifest .versions .iter() .find(|version| version.version_id == version_id) + .filter(|_| existing_journal.is_none()) { ledger.result_version_id = Some(existing.version_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; @@ -1835,8 +2091,11 @@ fn commit_resource_edit_version( Some(&existing.version_id), ); } - if current_revision.revision != input.expected_project_revision { - return Err("project-revision-conflict".to_string()); + let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; + if fresh_source.canonical_resource_id != source.canonical_resource_id + || fresh_source.source_sha256 != source.source_sha256 + { + return Err("source-resource-conflict".to_string()); } let source_version = manifest .versions @@ -1849,47 +2108,257 @@ fn commit_resource_edit_version( }) .cloned() .ok_or_else(|| "源项目版本不存在".to_string())?; - let target_revision = current_revision - .revision - .checked_add(1) - .ok_or_else(|| "项目 revision 已达到上限".to_string())?; - let version = shared_contracts::game_creation_app::GameIterationVersion { - version_id: version_id.clone(), - parent_version_id: Some(source_version.version_id), - project_revision: target_revision, - resource_bindings: source_version.resource_bindings, - created_reason: - shared_contracts::game_creation_app::GameIterationVersionCreatedReason::AgentRevision, - created_at: unix_timestamp(), - edit_prompt: Some(prompt.to_string()), - }; - manifest.versions.push(version.clone()); - let mut target_revision_state = current_revision.clone(); - target_revision_state.revision = target_revision; - target_revision_state.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_project_revision(root, &target_revision_state)?; - if let Err(error) = write_manifest(&root.join(".agent/manifest.json"), &manifest) { - let rollback = write_game_creator_agent_runtime_project_revision(root, ¤t_revision); - return Err(match rollback { - Ok(()) => error, - Err(rollback_error) => { - format!("{error};回滚项目 revision 失败,需要对账:{rollback_error}") + let mut journal = match existing_journal { + Some(journal) => { + if journal.schema_version != RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION + || journal.operation_id != input.operation_id + || journal.project_id != input.expected_project_id + || journal.source_version_id != source_version.version_id + || journal.version.version_id != version_id + || journal.version.parent_version_id.as_deref() + != Some(source_version.version_id.as_str()) + || journal.version.edit_prompt.as_deref() != Some(prompt) + { + return Err("reconciliation-required: 资源编辑版本事务日志身份不一致".to_string()); } - }); + journal + } + None => { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let target_revision = current_revision + .revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + let now = unix_timestamp(); + let journal = ResourceEditVersionJournal { + schema_version: RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION.to_string(), + operation_id: input.operation_id.clone(), + project_id: input.expected_project_id.clone(), + source_version_id: source_version.version_id.clone(), + base_project_revision: current_revision.revision, + target_project_revision: target_revision, + version: shared_contracts::game_creation_app::GameIterationVersion { + version_id: version_id.clone(), + parent_version_id: Some(source_version.version_id.clone()), + project_revision: target_revision, + resource_bindings: source_version.resource_bindings.clone(), + created_reason: shared_contracts::game_creation_app::GameIterationVersionCreatedReason::AgentRevision, + created_at: now, + edit_prompt: Some(prompt.to_string()), + }, + phase: ResourceEditVersionJournalPhase::Prepared, + created_at: now, + updated_at: now, + }; + write_resource_edit_version_journal(root, &journal)?; + journal + } + }; + + let matching_versions = manifest + .versions + .iter() + .filter(|version| version.version_id == version_id) + .collect::>(); + if matching_versions.len() > 1 + || matching_versions + .first() + .is_some_and(|version| **version != journal.version) + { + return Err("reconciliation-required: 派生子版本出现重复或内容冲突".to_string()); } + if matching_versions.is_empty() { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if current_revision.revision != journal.base_project_revision { + return Err("reconciliation-required: 子版本写入前项目 revision 已偏移".to_string()); + } + manifest.versions.push(journal.version.clone()); + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + update_resource_edit_version_journal_phase( + root, + &mut journal, + ResourceEditVersionJournalPhase::ManifestWritten, + )?; + } + + let mut current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if current_revision.revision == journal.base_project_revision { + current_revision.revision = journal.target_project_revision; + current_revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, ¤t_revision)?; + update_resource_edit_version_journal_phase( + root, + &mut journal, + ResourceEditVersionJournalPhase::RevisionWritten, + )?; + } else if current_revision.revision < journal.target_project_revision { + return Err("reconciliation-required: 子版本事务 revision 状态无效".to_string()); + } + update_resource_edit_version_journal_phase( + root, + &mut journal, + ResourceEditVersionJournalPhase::Committed, + )?; + let version = journal.version.clone(); ledger.result_version_id = Some(version.version_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), source_resource_id: source.canonical_resource_id.clone(), - committed_project_revision: target_revision, + committed_project_revision: current_revision.revision, asset: None, version: Some(version), manifest, }) } +pub(crate) fn list_pending_local_project_resource_edits_at( + input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取资源编辑账本目录失败:{error}")), + }; + let mut pending = Vec::new(); + let mut scanned_entries = 0_usize; + for entry in entries { + let entry = entry.map_err(|error| format!("读取资源编辑账本失败:{error}"))?; + scanned_entries += 1; + if scanned_entries > RESOURCE_EDIT_LEDGER_SCAN_MAX_ENTRIES { + return Err("资源编辑账本数量超过安全扫描上限".to_string()); + } + if !entry + .file_type() + .map_err(|error| format!("读取资源编辑账本类型失败:{error}"))? + .is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("json") + { + continue; + } + let entry_path = entry.path(); + let operation_id = entry_path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "资源编辑账本文件名无效".to_string())?; + let ledger = read_resource_edit_ledger(root, operation_id)? + .ok_or_else(|| "资源编辑账本扫描结果不一致".to_string())?; + if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION + || ledger.operation_id != operation_id + || ledger.project_id != input.expected_project_id + { + return Err("资源编辑账本身份无效".to_string()); + } + if ledger.phase != ResourceEditLedgerPhase::Committed { + pending.push(PendingLocalProjectResourceEdit { + operation_id: ledger.operation_id, + edit_kind: ledger.edit_kind, + source_resource_id: ledger.source_resource_id, + asset_name: ledger.asset_name, + phase: ledger.phase.as_str().to_string(), + created_at: ledger.created_at, + }); + } + } + pending.sort_by_key(|edit| (edit.created_at, edit.operation_id.clone())); + Ok(pending) +} + +pub(crate) async fn resume_local_project_resource_edit_at( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId")?; + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let ledger = read_resource_edit_ledger(root, &input.operation_id)? + .ok_or_else(|| "待恢复的资源编辑账本不存在".to_string())?; + if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION + || ledger.project_id != input.expected_project_id + || ledger.operation_id != input.operation_id + { + return Err("待恢复的资源编辑账本身份无效".to_string()); + } + let source_asset = ledger + .source_asset_id + .as_deref() + .and_then(|asset_id| manifest.assets.iter().find(|asset| asset.id == asset_id)) + .or_else(|| { + manifest.assets.iter().find(|asset| { + source_asset_canonical_resource_id(asset) == ledger.source_resource_id + || ledger.source_path.as_deref() == Some(asset.local_path.as_str()) + }) + }) + .cloned(); + let producer_task_id = ledger.producer_task_id.clone().or_else(|| { + source_asset + .as_ref() + .and_then(|asset| asset.source.task_id.clone()) + .or_else(|| { + ledger.source_path.as_deref().and_then(|path| { + manifest + .tasks + .iter() + .find(|task| task.artifacts.iter().any(|artifact| artifact == path)) + .map(|task| task.id.clone()) + }) + }) + }); + let source_version_id = ledger.source_version_id.clone().or_else(|| { + (ledger.edit_kind == LocalProjectResourceEditKind::Version) + .then(|| { + ledger + .source_resource_id + .strip_prefix("version:") + .map(str::to_string) + }) + .flatten() + }); + let source_media_type = ledger + .source_media_type + .clone() + .or_else(|| source_asset.as_ref().map(|asset| asset.media_type.clone())) + .or_else(|| { + infer_resource_edit_source_media_type(&ledger.edit_kind, ledger.source_path.as_deref()) + }); + let source_subtype = ledger + .source_asset_kind + .clone() + .or_else(|| source_asset.as_ref().map(|asset| asset.kind.clone())) + .or_else(|| Some(infer_resource_edit_source_asset_kind(&ledger.edit_kind))); + derive_local_project_resource_at(DeriveLocalProjectResourceInput { + project_path: input.project_path, + expected_project_id: input.expected_project_id, + expected_project_revision: ledger.expected_project_revision, + operation_id: ledger.operation_id, + idempotency_key: ledger.idempotency_key, + edit_kind: ledger.edit_kind, + source_resource_id: ledger.source_resource_id, + source_asset_id: ledger + .source_asset_id + .or_else(|| source_asset.as_ref().map(|asset| asset.id.clone())), + source_path: ledger.source_path, + source_media_type, + source_subtype, + producer_task_id, + source_version_id, + prompt: ledger.prompt, + asset_name: ledger.asset_name, + }) + .await +} + pub(crate) async fn derive_local_project_resource_at( input: DeriveLocalProjectResourceInput, ) -> Result { @@ -1913,11 +2382,22 @@ pub(crate) async fn derive_local_project_resource_at( let source = resolve_resource_edit_source(root, &manifest, &input)?; let request_fingerprint = resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; + let legacy_request_fingerprint = + legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; let now = unix_timestamp(); - let mut ledger = match read_resource_edit_ledger(root, &input.operation_id)? { + let existing_ledger = read_resource_edit_ledger(root, &input.operation_id)?; + if existing_ledger.is_none() + && read_game_creator_agent_runtime_project_revision(root)?.revision + != input.expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + let mut ledger = match existing_ledger { Some(ledger) => { - if ledger.request_fingerprint != request_fingerprint - || ledger.idempotency_key != input.idempotency_key + if !matches!( + ledger.request_fingerprint.as_str(), + value if value == request_fingerprint || value == legacy_request_fingerprint + ) || ledger.idempotency_key != input.idempotency_key || ledger.project_id != input.expected_project_id { return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); @@ -1934,7 +2414,22 @@ pub(crate) async fn derive_local_project_resource_at( project_id: input.expected_project_id.clone(), expected_project_revision: input.expected_project_revision, source_resource_id: source.canonical_resource_id.clone(), + source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), + source_media_type: Some(source.media_type.clone()), + source_asset_kind: Some(source.asset_kind.clone()), + producer_task_id: input.producer_task_id.clone().or_else(|| { + source + .source_asset + .as_ref() + .and_then(|asset| asset.source.task_id.clone()) + }), + source_version_id: input.source_version_id.clone().or_else(|| { + source + .source_version + .as_ref() + .map(|version| version.version_id.clone()) + }), source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), @@ -2113,7 +2608,22 @@ mod tests { project_id: input.expected_project_id.clone(), expected_project_revision: input.expected_project_revision, source_resource_id: source.canonical_resource_id.clone(), + source_asset_id: source.source_asset.as_ref().map(|asset| asset.id.clone()), source_path: source.source_path.clone(), + source_media_type: Some(source.media_type.clone()), + source_asset_kind: Some(source.asset_kind.clone()), + producer_task_id: input.producer_task_id.clone().or_else(|| { + source + .source_asset + .as_ref() + .and_then(|asset| asset.source.task_id.clone()) + }), + source_version_id: input.source_version_id.clone().or_else(|| { + source + .source_version + .as_ref() + .map(|version| version.version_id.clone()) + }), source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), @@ -2211,6 +2721,141 @@ mod tests { assert!(error.contains("内容冲突")); } + #[tokio::test] + async fn derived_video_reuses_committed_object_key_instead_of_asset_object_id() { + let directory = tempfile::tempdir().expect("create committed video fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "派生视频二次编辑测试") + .expect("initialize project"); + let source_bytes = b"\0\0\0\x18ftypisom\0\0\0\0isomiso2"; + let uploaded = upload_local_asset_at(root, "derived-video.mp4", "video/mp4", source_bytes) + .expect("register derived video"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + let mut prior_input = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + format!("asset:{}", source_asset.id), + ); + prior_input.source_asset_id = Some(source_asset.id.clone()); + prior_input.source_path = Some(source_asset.local_path.clone()); + prior_input.source_media_type = Some(source_asset.media_type.clone()); + let source = + resolve_resource_edit_source(root, &manifest, &prior_input).expect("resolve source"); + let mut committed = ledger_for(&prior_input, &source, ResourceEditLedgerPhase::Committed); + committed.result_asset_id = Some(source_asset.id.clone()); + committed.remote_object_key = Some("generated/stable-video.mp4".to_string()); + committed.remote_asset_object_id = Some("asset-object-must-not-be-used".to_string()); + write_resource_edit_ledger(root, &committed).expect("write committed ledger"); + + let mut current_input = prior_input.clone(); + current_input.operation_id = Uuid::new_v4().to_string(); + current_input.idempotency_key = Uuid::new_v4().to_string(); + let mut current = ledger_for(¤t_input, &source, ResourceEditLedgerPhase::Prepared); + let reference = ensure_resource_edit_source_reference( + root, + &reqwest::Client::new(), + "http://127.0.0.1:9", + "unused-key", + ¤t_input, + &source, + &mut current, + ) + .await + .expect("reuse committed object key without upload"); + + assert_eq!(reference, "generated/stable-video.mp4"); + assert_ne!(reference, "asset-object-must-not-be-used"); + assert_eq!( + current.source_stable_reference.as_deref(), + Some(reference.as_str()) + ); + } + + #[tokio::test] + async fn authentication_and_missing_status_keep_the_original_operation_ledger() { + let directory = tempfile::tempdir().expect("create operation preservation fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "远端错误保留 operation 测试") + .expect("initialize project"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:video-one".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/video-one.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.endpoint = Some("/api/external/v1/editor/videos/generations".to_string()); + ledger.request_body_json = Some("{}".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write original ledger"); + let client = reqwest::Client::new(); + + for status in ["401 Unauthorized", "403 Forbidden"] { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("auth address")); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept auth request"); + let _ = read_http_request(&mut stream); + write_json(&mut stream, status, serde_json::json!({"error": "denied"})); + }); + let error = submit_resource_edit_remote(&client, &base_url, "rotated-key", &ledger) + .await + .expect_err("authentication status must fail"); + server.join().expect("join auth fixture"); + assert!(error.contains("authentication-required")); + } + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind missing status fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("missing status address") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept status request"); + let _ = read_http_request(&mut stream); + write_json( + &mut stream, + "404 Not Found", + serde_json::json!({"error": "missing"}), + ); + }); + let error = wait_for_resource_edit_remote(&client, &base_url, "rotated-key", "remote-one") + .await + .expect_err("missing remote status must stay unknown"); + server.join().expect("join missing status fixture"); + assert!(error.contains("result-unknown")); + + let operations = fs::read_dir(root.join(format!("{RESOURCE_EDIT_ROOT}/operations"))) + .expect("read operations") + .collect::, _>>() + .expect("collect operations"); + assert_eq!(operations.len(), 1); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read ledger") + .expect("original ledger") + .operation_id, + request.operation_id + ); + } + #[test] fn remote_requests_keep_resource_editor_queue_identity_and_endpoint_limits() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -2551,8 +3196,14 @@ mod tests { ); assert_eq!( first.asset.source.resource_id.as_deref(), - Some(request.source_resource_id.as_str()) + Some(format!("local-asset:{}", first.asset.id).as_str()) ); + assert!(!first + .asset + .source + .resource_id + .as_deref() + .is_some_and(|resource_id| resource_id.starts_with("task:"))); assert_eq!( fs::read(root.join("assets/task-hero.png")).expect("read source PNG"), [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00] @@ -2612,6 +3263,8 @@ mod tests { b"# Original rules\n\nRed hair\n", ) .expect("stage derivative"); + advance_agent_runtime_project_revision_locked(root) + .expect("simulate unrelated project mutation during remote edit"); let result = commit_resource_edit_asset( root, @@ -2622,6 +3275,7 @@ mod tests { &mut ledger, ) .expect("commit derivative"); + assert_eq!(result.committed_project_revision, 2); let derivative = result.asset.expect("derivative asset"); assert_ne!(derivative.id, source_asset.id); assert_ne!(derivative.local_path, source_asset.local_path); @@ -2641,6 +3295,60 @@ mod tests { ); } + #[test] + fn asset_commit_rejects_source_content_change_after_remote_generation() { + let directory = tempfile::tempdir().expect("create source conflict fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "源资源冲突测试").expect("initialize project"); + let uploaded = upload_local_asset_at(root, "rules.md", "text/markdown", b"original\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request).expect("source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_staging(root, &request.operation_id, b"derivative\n") + .expect("stage derivative"); + fs::write( + root.join(&source_asset.local_path), + b"changed while generating\n", + ) + .expect("mutate source"); + + let error = commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + .expect_err("changed source must fail closed"); + assert_eq!(error, "source-resource-conflict"); + assert_eq!( + read_existing_manifest_for_project(root) + .expect("read unchanged manifest") + .assets + .len(), + 1 + ); + } + #[test] fn version_commit_appends_child_without_mutating_parent() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -2680,4 +3388,302 @@ mod tests { assert_eq!(result.manifest.versions[0], parent); assert_eq!(result.manifest.versions.len(), 2); } + + #[test] + fn version_commit_recovers_manifest_written_crash_without_duplicate_child() { + let directory = tempfile::tempdir().expect("create version journal fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "版本事务恢复测试") + .expect("initialize project"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let parent = shared_contracts::game_creation_app::GameIterationVersion { + version_id: "version-parent".to_string(), + parent_version_id: None, + project_revision: 0, + resource_bindings: Vec::new(), + created_reason: + shared_contracts::game_creation_app::GameIterationVersionCreatedReason::Initial, + created_at: 1, + edit_prompt: None, + }; + manifest.versions.push(parent.clone()); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write parent"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Version, + "version:version-parent".to_string(), + ); + request.source_version_id = Some(parent.version_id.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request).expect("source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + let child = shared_contracts::game_creation_app::GameIterationVersion { + version_id: format!("edit-{}", request.operation_id), + parent_version_id: Some(parent.version_id.clone()), + project_revision: 1, + resource_bindings: Vec::new(), + created_reason: + shared_contracts::game_creation_app::GameIterationVersionCreatedReason::AgentRevision, + created_at: 2, + edit_prompt: Some(request.prompt.clone()), + }; + write_resource_edit_version_journal( + root, + &ResourceEditVersionJournal { + schema_version: RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION.to_string(), + operation_id: request.operation_id.clone(), + project_id: PROJECT_ID.to_string(), + source_version_id: parent.version_id.clone(), + base_project_revision: 0, + target_project_revision: 1, + version: child.clone(), + phase: ResourceEditVersionJournalPhase::Prepared, + created_at: 2, + updated_at: 2, + }, + ) + .expect("prepare journal"); + manifest.versions.push(child); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("simulate manifest-written crash"); + + let result = + commit_resource_edit_version(root, &request, &source, &request.prompt, &mut ledger) + .expect("recover version transaction"); + assert_eq!(result.committed_project_revision, 1); + assert_eq!(result.manifest.versions.len(), 2); + assert_eq!( + read_resource_edit_version_journal(root, &request.operation_id) + .expect("read journal") + .expect("journal") + .phase, + ResourceEditVersionJournalPhase::Committed + ); + + let replay = + commit_resource_edit_version(root, &request, &source, &request.prompt, &mut ledger) + .expect("replay committed journal"); + assert_eq!(replay.committed_project_revision, 1); + assert_eq!(replay.manifest.versions.len(), 2); + } + + #[tokio::test] + async fn pending_task_video_and_version_resume_from_persisted_source_snapshots() { + let task_directory = tempfile::tempdir().expect("create task video fixture"); + let task_root = task_directory.path(); + init_local_game_project_at(task_root, PROJECT_ID, "任务视频恢复测试") + .expect("initialize task project"); + fs::create_dir_all(task_root.join("assets")).expect("create task assets"); + let source_video = b"\0\0\0\x18ftypisom\0\0\0\0isomiso2"; + fs::write(task_root.join("assets/task-video.mp4"), source_video).expect("write task video"); + let mut task_manifest = + read_existing_manifest_for_project(task_root).expect("read task manifest"); + let task = task_manifest.tasks.first_mut().expect("seed task"); + task.status = GameCreationAppTaskStatus::Completed; + task.artifacts = vec!["assets/task-video.mp4".to_string()]; + let task_id = task.id.clone(); + write_manifest(&task_root.join(".agent/manifest.json"), &task_manifest) + .expect("write completed task"); + let mut task_request = input( + task_root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + format!("task:{task_id}:assets/task-video.mp4"), + ); + task_request.source_path = Some("assets/task-video.mp4".to_string()); + task_request.source_media_type = Some("video/mp4".to_string()); + task_request.source_subtype = Some("video".to_string()); + task_request.producer_task_id = Some(task_id.clone()); + let task_source = resolve_resource_edit_source(task_root, &task_manifest, &task_request) + .expect("resolve task video"); + let mut task_ledger = ledger_for( + &task_request, + &task_source, + ResourceEditLedgerPhase::MediaDownloaded, + ); + task_ledger.request_fingerprint = resource_edit_request_fingerprint( + &task_request, + &task_source, + &task_request.prompt, + &task_request.asset_name, + ) + .expect("task fingerprint"); + task_ledger.staged_media_type = Some("video/mp4".to_string()); + task_ledger.staged_extension = Some("mp4".to_string()); + write_resource_edit_ledger(task_root, &task_ledger).expect("write task ledger"); + write_resource_edit_staging(task_root, &task_request.operation_id, source_video) + .expect("stage task derivative"); + + let task_result = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: task_root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: task_request.operation_id.clone(), + }) + .await + .expect("resume task video"); + let task_derivative = task_result.asset.expect("task derivative"); + assert_eq!(task_derivative.media_type, "video/mp4"); + assert_eq!( + task_derivative.source.task_id.as_deref(), + Some(task_id.as_str()) + ); + + let version_directory = tempfile::tempdir().expect("create version fixture"); + let version_root = version_directory.path(); + init_local_game_project_at(version_root, PROJECT_ID, "版本恢复测试") + .expect("initialize version project"); + let mut version_manifest = + read_existing_manifest_for_project(version_root).expect("read version manifest"); + let parent = shared_contracts::game_creation_app::GameIterationVersion { + version_id: "version-resume-parent".to_string(), + parent_version_id: None, + project_revision: 0, + resource_bindings: Vec::new(), + created_reason: + shared_contracts::game_creation_app::GameIterationVersionCreatedReason::Initial, + created_at: 1, + edit_prompt: None, + }; + version_manifest.versions.push(parent.clone()); + write_manifest( + &version_root.join(".agent/manifest.json"), + &version_manifest, + ) + .expect("write version parent"); + let mut version_request = input( + version_root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Version, + format!("version:{}", parent.version_id), + ); + version_request.source_version_id = Some(parent.version_id.clone()); + let version_source = + resolve_resource_edit_source(version_root, &version_manifest, &version_request) + .expect("resolve version source"); + let mut version_ledger = ledger_for( + &version_request, + &version_source, + ResourceEditLedgerPhase::Prepared, + ); + version_ledger.request_fingerprint = resource_edit_request_fingerprint( + &version_request, + &version_source, + &version_request.prompt, + &version_request.asset_name, + ) + .expect("version fingerprint"); + write_resource_edit_ledger(version_root, &version_ledger).expect("write version ledger"); + + let version_result = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: version_root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: version_request.operation_id, + }) + .await + .expect("resume version"); + assert_eq!( + version_result + .version + .expect("derived version") + .parent_version_id + .as_deref(), + Some(parent.version_id.as_str()) + ); + } + + #[test] + fn pending_scan_caps_all_directory_entries_not_only_pending_json_ledgers() { + let directory = tempfile::tempdir().expect("create scan cap fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "账本扫描上限测试") + .expect("initialize project"); + let operations = root.join(format!("{RESOURCE_EDIT_ROOT}/operations")); + fs::create_dir_all(&operations).expect("create operations directory"); + for index in 0..=RESOURCE_EDIT_LEDGER_SCAN_MAX_ENTRIES { + fs::write(operations.join(format!("ignored-{index}.txt")), b"ignored") + .expect("write ignored entry"); + } + + let error = list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect_err("all scanned entries must count toward the cap"); + assert!(error.contains("安全扫描上限")); + } + + #[tokio::test] + async fn pending_resource_edit_is_listed_and_resumed_from_ledger_only() { + let directory = tempfile::tempdir().expect("create pending edit fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑恢复测试") + .expect("initialize project"); + let uploaded = + upload_local_asset_at(root, "rules.md", "text/markdown", b"# Original rules\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .expect("source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request).expect("source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.request_fingerprint = resource_edit_request_fingerprint( + &request, + &source, + &request.prompt, + &request.asset_name, + ) + .expect("fingerprint"); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write ledger"); + write_resource_edit_staging( + root, + &request.operation_id, + b"# Original rules\n\nRecovered edit\n", + ) + .expect("write staging"); + + let pending = list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list pending"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].operation_id, request.operation_id); + + let result = resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect("resume from ledger"); + assert!(result.asset.is_some()); + assert!(list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list after commit") + .is_empty()); + } } 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 c11bf1217..901fc83af 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 @@ -18,6 +18,7 @@ import { moveViewportFromPan, removeCanvasLayers, resizeCanvasLayerBounds, + resolveViewportFromWheel, scaleViewportFromScreenPoint, transformCanvasLayers, } from '@genarrative/image-canvas-core'; @@ -387,6 +388,7 @@ export function AssetCanvasSurface({ const draftRef = useRef(draft); const lifecycleRef = useRef(lifecycle); const documentVersionRef = useRef(documentVersion); + const persistedDocumentVersionRef = useRef(0); const epochRef = useRef(0); const dragRef = useRef(null); const saveQueueRef = useRef>(Promise.resolve()); @@ -402,6 +404,7 @@ export function AssetCanvasSurface({ const pendingGenerationRef = useRef(null); const generationStartingRef = useRef(false); const generationFocusEpochRef = useRef(0); + const generationStopButtonRef = useRef(null); const onWalletBalanceMayHaveChanged = useWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); @@ -421,7 +424,12 @@ export function AssetCanvasSurface({ documentVersionRef.current = documentVersion; const markDirty = useCallback(() => { - setDocumentVersion((value) => value + 1); + if (lifecycleRef.current.kind !== 'canvas.editing') return; + setDocumentVersion((value) => { + const next = value + 1; + documentVersionRef.current = next; + return next; + }); setLifecycle({ kind: 'canvas.editing', dirty: true }); }, []); @@ -441,8 +449,21 @@ export function AssetCanvasSurface({ const nextDraft = { ...currentDraft, revision }; draftRef.current = nextDraft; setDraft(nextDraft); + const epoch = epochRef.current; + void host.project.loadDraft(stableScope).then((loaded) => { + if ( + epoch !== epochRef.current || + loaded.status !== 'ok' || + !loaded.value || + loaded.value.revision < revision + ) { + return; + } + draftRef.current = loaded.value; + setDraft(loaded.value); + }); }, - [], + [host.project, stableScope], ); const canvasHistoryRefs = useMemo( @@ -538,6 +559,9 @@ export function AssetCanvasSurface({ setBackgroundColor(nextDraft.canvas.backgroundColor); setSelectedLayerIds(nextDraft.canvas.selectedLayerIds); resetCanvasHistory(); + documentVersionRef.current = 0; + persistedDocumentVersionRef.current = 0; + setDocumentVersion(0); setLifecycle({ kind: 'canvas.editing', dirty: false }); }, [host, resetCanvasHistory, stableScope], @@ -552,6 +576,7 @@ export function AssetCanvasSurface({ pendingCommitRef.current = null; pendingGenerationRef.current = null; generationStartingRef.current = false; + dragRef.current = null; generationFocusEpochRef.current += 1; hostRevisionRef.current = expectedHostRevision; deliveredEventsRef.current.clear(); @@ -710,7 +735,7 @@ export function AssetCanvasSurface({ const persistDraft = useCallback(async (): Promise => { const epoch = epochRef.current; - const requestedVersion = documentVersion; + const requestedVersion = documentVersionRef.current; const task = saveQueueRef.current.then(async () => { const currentDraft = draftRef.current; if (!currentDraft || epoch !== epochRef.current) return null; @@ -748,14 +773,21 @@ export function AssetCanvasSurface({ } draftRef.current = result.value; setDraft(result.value); - if (requestedVersion === documentVersionRef.current) { + persistedDocumentVersionRef.current = Math.max( + persistedDocumentVersionRef.current, + requestedVersion, + ); + if ( + requestedVersion === documentVersionRef.current && + lifecycleRef.current.kind === 'canvas.editing' + ) { setLifecycle({ kind: 'canvas.editing', dirty: false }); } return result.value; }); saveQueueRef.current = task.catch(() => undefined); return await task; - }, [documentVersion, host.project, stableScope]); + }, [host.project, stableScope]); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -767,6 +799,10 @@ export function AssetCanvasSurface({ useEffect(() => { const onMove = (event: PointerEvent) => { + if (lifecycleRef.current.kind !== 'canvas.editing') { + dragRef.current = null; + return; + } const drag = dragRef.current; if (!drag) return; if (drag.kind === 'pan') { @@ -826,6 +862,10 @@ export function AssetCanvasSurface({ } }; const onUp = () => { + if (lifecycleRef.current.kind !== 'canvas.editing') { + dragRef.current = null; + return; + } if (!dragRef.current) return; dragRef.current = null; markDirty(); @@ -845,7 +885,12 @@ export function AssetCanvasSurface({ const files = Array.from(event.target.files ?? []); event.target.value = ''; const currentDraft = draftRef.current; - if (!currentDraft || !files.length) return; + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + !currentDraft || + !files.length + ) + return; const images = await Promise.all( files.map(async (file) => { const mediaType = mediaTypeForFile(file); @@ -931,7 +976,11 @@ export function AssetCanvasSurface({ ); const deleteSelected = useCallback(() => { - if (!selectionRef.current.length) return; + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + !selectionRef.current.length + ) + return; captureHistory({ type: 'delete-image', count: selectionRef.current.length, @@ -971,8 +1020,7 @@ export function AssetCanvasSurface({ const epoch = saveEpoch; if (!draftRef.current) return; if ( - lifecycleRef.current.kind === 'canvas.editing' && - lifecycleRef.current.dirty + documentVersionRef.current !== persistedDocumentVersionRef.current ) { setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); const persisted = await persistDraft(); @@ -1053,6 +1101,7 @@ export function AssetCanvasSurface({ }); } pendingCommitRef.current = null; + persistedDocumentVersionRef.current = documentVersionRef.current; setNotice( result.value.commitStatus === 'already-committed' ? '素材已提交,本次返回原幂等结果' @@ -1162,10 +1211,7 @@ export function AssetCanvasSurface({ ) { return; } - if ( - lifecycleRef.current.kind === 'canvas.editing' && - lifecycleRef.current.dirty - ) { + if (documentVersionRef.current !== persistedDocumentVersionRef.current) { setExitDialogOpen(true); return; } @@ -1237,9 +1283,9 @@ export function AssetCanvasSurface({ const frozenAssetKind = assetKind; const frozenAssetName = assetName; const needsDraftPersist = - lifecycleRef.current.kind === 'canvas.editing' && - lifecycleRef.current.dirty; + documentVersionRef.current !== persistedDocumentVersionRef.current; setGenerationDialog(null); + dragRef.current = null; setLifecycle({ kind: 'canvas.generating', phase: 'confirmation-required', @@ -1347,6 +1393,7 @@ export function AssetCanvasSurface({ }); } pendingGenerationRef.current = null; + persistedDocumentVersionRef.current = documentVersionRef.current; setLifecycle({ kind: 'canvas.editing', dirty: false }); setNotice('AI 图片已正式提交并进入资源总览'); })().catch((error: unknown) => { @@ -1419,6 +1466,14 @@ export function AssetCanvasSurface({ () => createMinimapModel({ layers, viewport, canvasSize }), [canvasSize, layers, viewport], ); + const generationInteractionLocked = lifecycle.kind === 'canvas.generating'; + + useEffect(() => { + if (generationInteractionLocked) { + dragRef.current = null; + generationStopButtonRef.current?.focus(); + } + }, [generationInteractionLocked]); if ( !draft || @@ -1829,6 +1884,7 @@ export function AssetCanvasSurface({ />

生成会由平台按当前账号扣除泥点,完成后自动保存到本地项目。