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 953625d2d..0c93e4041 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -271,6 +271,14 @@ pub(crate) fn read_local_project_asset_canvas_draft( read_asset_canvas_draft_at(&root, &input) } +#[tauri::command] +pub(crate) fn discover_local_project_asset_canvas_draft( + input: DiscoverAssetCanvasDraftInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + discover_asset_canvas_draft_at(&root, &input) +} + #[tauri::command] pub(crate) fn update_local_project_asset_canvas_draft( input: UpdateAssetCanvasDraftInput, @@ -1271,6 +1279,24 @@ pub(crate) async fn resume_local_project_resource_edit( resume_local_project_resource_edit_at(input).await } +#[tauri::command] +pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( + input: RequestResourceEditServiceIdentityConfirmationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + request_resource_edit_service_identity_confirmation_at(input).await +} + +#[tauri::command] +pub(crate) async fn confirm_local_project_resource_edit_service_identity( + input: ConfirmResourceEditServiceIdentityInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + confirm_resource_edit_service_identity_at(input).await +} + #[tauri::command] pub(crate) async fn archive_failed_local_project_resource_edit( input: ArchiveFailedLocalProjectResourceEditInput, 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 1924790a8..8bbdd75d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2212,6 +2212,8 @@ fn main() { derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, + request_local_project_resource_edit_service_identity_confirmation, + confirm_local_project_resource_edit_service_identity, archive_failed_local_project_resource_edit, normalize_local_project_raster_resource, import_canvas_asset, @@ -2263,6 +2265,7 @@ fn main() { update_local_project_resource_canvas_layout, create_local_project_asset_canvas_draft, read_local_project_asset_canvas_draft, + discover_local_project_asset_canvas_draft, update_local_project_asset_canvas_draft, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, 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 406166c3e..83e61551c 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 @@ -23,6 +23,7 @@ const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024; const ASSET_CANVAS_MAX_LAYERS: usize = 4096; const ASSET_CANVAS_MAX_GENERATIONS: usize = 64; const ASSET_CANVAS_MAX_REFERENCES: usize = 128; +const ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES: usize = 4096; const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384; const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456; @@ -248,6 +249,29 @@ pub(crate) struct ReadAssetCanvasDraftResult { pub(crate) draft: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct DiscoverAssetCanvasDraftInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) intent: AssetCanvasIntent, + pub(crate) source_asset_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum DiscoverAssetCanvasDraftStatus { + Found, + NotFound, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiscoverAssetCanvasDraftResult { + pub(crate) status: DiscoverAssetCanvasDraftStatus, + pub(crate) draft: Option, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct UpdateAssetCanvasDraftInput { @@ -1231,6 +1255,94 @@ pub(crate) fn read_asset_canvas_draft_at( }) } +pub(crate) fn discover_asset_canvas_draft_at( + root: &Path, + input: &DiscoverAssetCanvasDraftInput, +) -> Result { + match (&input.intent, input.source_asset_id.as_deref()) { + (AssetCanvasIntent::Create, None) => {} + (AssetCanvasIntent::Refine, Some(source_asset_id)) => { + validate_plain_component(source_asset_id, "sourceAssetId", 512)?; + } + (AssetCanvasIntent::Create, Some(_)) => { + return Err("create 草稿发现不能指定 sourceAssetId".to_string()); + } + (AssetCanvasIntent::Refine, None) => { + return Err("refine 草稿发现必须指定 sourceAssetId".to_string()); + } + } + let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, &preflight.project_id)?; + if preflight.project_id != manifest.project_id { + return Err("项目身份在素材画布草稿发现期间发生变化".to_string()); + } + let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/drafts"))?; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DiscoverAssetCanvasDraftResult { + status: DiscoverAssetCanvasDraftStatus::NotFound, + draft: None, + }); + } + Err(error) => return Err(format!("读取素材画布草稿目录失败:{error}")), + }; + let mut scanned_entries = 0_usize; + let mut matching = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| format!("读取素材画布草稿条目失败:{error}"))?; + scanned_entries += 1; + if scanned_entries > ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES { + return Err("素材画布草稿数量超过安全扫描上限".to_string()); + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + if !entry + .file_type() + .map_err(|error| format!("读取素材画布草稿类型失败:{error}"))? + .is_file() + { + return Err("素材画布草稿条目必须是普通 JSON 文件".to_string()); + } + let draft_id = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| "素材画布草稿文件名无效".to_string())?; + validate_uuid_v4(draft_id, "草稿文件名")?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? + .ok_or_else(|| "素材画布草稿扫描结果不一致".to_string())?; + if draft.intent == input.intent + && draft.source_asset_id == input.source_asset_id + && matches!( + draft.status, + AssetCanvasDraftStatus::Editing + | AssetCanvasDraftStatus::Generating + | AssetCanvasDraftStatus::CommitPrepared + | AssetCanvasDraftStatus::ReconciliationRequired + ) + { + matching.push(draft); + } + } + match matching.len() { + 0 => Ok(DiscoverAssetCanvasDraftResult { + status: DiscoverAssetCanvasDraftStatus::NotFound, + draft: None, + }), + 1 => Ok(DiscoverAssetCanvasDraftResult { + status: DiscoverAssetCanvasDraftStatus::Found, + draft: matching.pop(), + }), + _ => Err( + "reconciliation-required: 同一来源存在多个可恢复素材画布草稿,不能猜测恢复目标" + .to_string(), + ), + } +} + pub(crate) fn update_asset_canvas_draft_at( root: &Path, input: &UpdateAssetCanvasDraftInput, 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 9bce10dce..ea302ecea 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 @@ -328,6 +328,73 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { .contains(&"local-asset:source-asset".to_string())); } +#[test] +fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() { + let directory = tempfile::tempdir().expect("create refine discovery fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "精修草稿发现测试项目") + .expect("initialize project"); + let source_bytes = png_bytes([80, 120, 160, 255]); + fs::write(root.join("assets/source.png"), source_bytes).expect("write source asset"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest"); + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: "source-asset".to_string(), + kind: "illustration".to_string(), + media_type: "image/png".to_string(), + local_path: "assets/source.png".to_string(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + }); + write_manifest(&manifest_path, &manifest).expect("register source asset"); + let discovery = DiscoverAssetCanvasDraftInput { + project_path: project_path(root), + expected_project_id: PROJECT_ID.to_string(), + intent: AssetCanvasIntent::Refine, + source_asset_id: Some("source-asset".to_string()), + }; + let first_id = Uuid::new_v4().to_string(); + create_asset_canvas_draft_at( + root, + &create_input( + root, + &first_id, + AssetCanvasIntent::Refine, + Some("source-asset".to_string()), + ), + ) + .expect("create first refine draft"); + + let found = discover_asset_canvas_draft_at(root, &discovery).expect("discover refine draft"); + assert_eq!(found.status, DiscoverAssetCanvasDraftStatus::Found); + assert_eq!(found.draft.expect("found draft").draft_id, first_id); + + let second_id = Uuid::new_v4().to_string(); + create_asset_canvas_draft_at( + root, + &create_input( + root, + &second_id, + AssetCanvasIntent::Refine, + Some("source-asset".to_string()), + ), + ) + .expect("create second refine draft"); + let error = discover_asset_canvas_draft_at(root, &discovery) + .expect_err("ambiguous refine drafts must fail closed"); + assert!(error.contains("多个可恢复素材画布草稿")); +} + #[test] fn draft_cas_continuous_saves_and_trusted_restart_recovery() { let fixture = initialize_fixture(); 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 3a8e0d158..f054e8ee9 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 @@ -21,6 +21,10 @@ const RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION: &str = "game-creator-resource-edit-version-transaction.v1"; const RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION: &str = "game-creator-resource-edit-asset-transaction.v1"; +const RESOURCE_EDIT_PROVIDER_HANDOFF_SCHEMA_VERSION: &str = + "game-creator-resource-edit-provider-handoff.v1"; +const RESOURCE_EDIT_PROVIDER_HANDOFF_MAX_BYTES: usize = 16 * 1024 * 1024; +const RESOURCE_EDIT_SERVICE_IDENTITY_CONFIRMATION_TTL_SECONDS: u64 = 10 * 60; type ResourceEditAsyncLock = tokio::sync::Mutex<()>; @@ -166,6 +170,45 @@ pub(crate) struct PendingLocalProjectResourceEdit { pub(crate) created_at: u64, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct RequestResourceEditServiceIdentityConfirmationInput { + 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 ConfirmResourceEditServiceIdentityInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) operation_id: String, + pub(crate) remote_operation_id: Option, + pub(crate) challenge: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResourceEditServiceIdentityConfirmation { + pub(crate) operation_id: String, + pub(crate) remote_operation_id: Option, + pub(crate) operation_state: String, + pub(crate) service_origin: String, + pub(crate) challenge: String, + pub(crate) expires_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConfirmResourceEditServiceIdentityResult { + pub(crate) operation_id: String, + pub(crate) remote_operation_id: Option, + pub(crate) operation_state: String, + pub(crate) service_origin: String, + pub(crate) identity_scheme: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ResumeLocalProjectResourceEditInput { @@ -278,9 +321,13 @@ struct ResourceEditLedger { prompt: String, asset_name: String, #[serde(default)] + provider_request_issued_at: Option, + #[serde(default)] api_identity_scheme: Option, #[serde(default)] api_identity_fingerprint: Option, + #[serde(default)] + service_identity_confirmation: Option, phase: ResourceEditLedgerPhase, endpoint: Option, request_body_json: Option, @@ -304,6 +351,27 @@ struct ResourceEditLedger { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ResourceEditPrivateServiceIdentityConfirmation { + challenge: String, + service_fingerprint: String, + ledger_snapshot_sha256: String, + expires_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ResourceEditProviderHandoff { + schema_version: String, + operation_id: String, + request_fingerprint: String, + source_sha256: String, + response_text: String, + response_sha256: String, + created_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] enum ResourceEditVersionJournalPhase { @@ -392,6 +460,10 @@ fn resource_edit_staging_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/staging/{operation_id}.bin") } +fn resource_edit_provider_handoff_path(operation_id: &str) -> String { + format!("{RESOURCE_EDIT_ROOT}/provider-handoffs/{operation_id}.json") +} + fn resource_edit_version_journal_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/version-transactions/{operation_id}.json") } @@ -400,6 +472,85 @@ fn resource_edit_asset_journal_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/asset-transactions/{operation_id}.json") } +fn read_resource_edit_provider_handoff( + root: &Path, + ledger: &ResourceEditLedger, +) -> Result, String> { + let Some(handoff) = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &resource_edit_provider_handoff_path(&ledger.operation_id), + "资源编辑 Provider 成功响应交接记录", + RESOURCE_EDIT_PROVIDER_HANDOFF_MAX_BYTES, + )? + else { + return Ok(None); + }; + if handoff.schema_version != RESOURCE_EDIT_PROVIDER_HANDOFF_SCHEMA_VERSION + || handoff.operation_id != ledger.operation_id + || handoff.request_fingerprint != ledger.request_fingerprint + || handoff.source_sha256 != ledger.source_sha256 + || handoff.response_text.len() > RESOURCE_EDIT_TEXT_MAX_BYTES * 2 + || handoff.response_sha256 != sha256_hex(handoff.response_text.as_bytes()) + || handoff.created_at == 0 + { + return Err("资源编辑 Provider 成功响应交接记录身份无效".to_string()); + } + Ok(Some(handoff)) +} + +fn write_resource_edit_provider_handoff( + root: &Path, + ledger: &ResourceEditLedger, + response_text: String, +) -> Result { + if response_text.len() > RESOURCE_EDIT_TEXT_MAX_BYTES * 2 { + return Err("资源编辑 Provider 成功响应超过持久交接上限".to_string()); + } + let handoff = ResourceEditProviderHandoff { + schema_version: RESOURCE_EDIT_PROVIDER_HANDOFF_SCHEMA_VERSION.to_string(), + operation_id: ledger.operation_id.clone(), + request_fingerprint: ledger.request_fingerprint.clone(), + source_sha256: ledger.source_sha256.clone(), + response_sha256: sha256_hex(response_text.as_bytes()), + response_text, + created_at: unix_timestamp(), + }; + if let Some(existing) = read_resource_edit_provider_handoff(root, ledger)? { + if existing == handoff + || (existing.operation_id == handoff.operation_id + && existing.request_fingerprint == handoff.request_fingerprint + && existing.source_sha256 == handoff.source_sha256 + && existing.response_sha256 == handoff.response_sha256 + && existing.response_text == handoff.response_text) + { + return Ok(existing); + } + return Err("同一资源编辑 operation 的 Provider 成功响应交接内容冲突".to_string()); + } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_provider_handoff_path(&ledger.operation_id), + "资源编辑 Provider 成功响应交接记录", + &handoff, + RESOURCE_EDIT_PROVIDER_HANDOFF_MAX_BYTES, + )?; + let persisted = read_resource_edit_provider_handoff(root, ledger)? + .ok_or_else(|| "资源编辑 Provider 成功响应交接写入后不存在".to_string())?; + if persisted != handoff { + return Err("资源编辑 Provider 成功响应交接写入后内容冲突".to_string()); + } + Ok(persisted) +} + +fn remove_resource_edit_provider_handoff(root: &Path, operation_id: &str) -> Result<(), String> { + let path = + resolve_local_project_path(root, &resource_edit_provider_handoff_path(operation_id))?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "资源编辑 Provider 成功响应交接记录")?; + remove_agent_runtime_json_sidecar_backup(&path, "资源编辑 Provider 成功响应交接记录") +} + fn read_resource_edit_asset_journal( root: &Path, operation_id: &str, @@ -1058,10 +1209,23 @@ fn validate_text_derivative( } async fn generate_resource_edit_text( + root: &Path, source: &ResourceEditSourceSnapshot, input: &DeriveLocalProjectResourceInput, prompt: &str, + ledger: &ResourceEditLedger, ) -> Result, String> { + if let Some(handoff) = read_resource_edit_provider_handoff(root, ledger) + .map_err(|error| format!("result-unknown: 资源编辑 Provider handoff 无法核验:{error}"))? + { + return parse_resource_edit_text_handoff(source, input, &handoff.response_text); + } + if ledger.provider_request_issued_at.is_some() { + return Err( + "result-unknown: 资源编辑 Provider 请求已发出但缺少可核验 handoff,禁止重复调用" + .to_string(), + ); + } let source_text = source .text .as_deref() @@ -1100,10 +1264,23 @@ async fn generate_resource_edit_text( &llm, false, )?; + let mut issued_ledger = ledger.clone(); + issued_ledger.provider_request_issued_at = Some(unix_timestamp()); + write_resource_edit_ledger(root, &issued_ledger)?; let response = request_game_creator_llm_text(&client, &llm, request) .await - .map_err(|error| format!("资源编辑 LLM 调用失败:{error}"))?; - let response_text = strip_llm_thinking_blocks(response.text.as_str()); + .map_err(|error| format!("result-unknown: 资源编辑 LLM 调用结果无法证明:{error}"))?; + let handoff = write_resource_edit_provider_handoff(root, &issued_ledger, response.text) + .map_err(|error| format!("result-unknown: Provider 已成功但持久化交接失败:{error}"))?; + parse_resource_edit_text_handoff(source, input, &handoff.response_text) +} + +fn parse_resource_edit_text_handoff( + source: &ResourceEditSourceSnapshot, + input: &DeriveLocalProjectResourceInput, + response_text: &str, +) -> Result, String> { + let response_text = strip_llm_thinking_blocks(response_text); let envelope = serde_json::from_str::(&response_text) .map_err(|error| format!("资源编辑 LLM 返回的结构化内容无效:{error}"))?; validate_text_derivative( @@ -1598,11 +1775,6 @@ async fn submit_resource_edit_remote( .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - update_resource_edit_phase( - root, - ledger, - ResourceEditLedgerPhase::ReconciliationRequired, - )?; return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); } if status == reqwest::StatusCode::BAD_REQUEST { @@ -1820,6 +1992,147 @@ fn validate_downloaded_media( } } +#[derive(Clone, Debug, Eq, PartialEq)] +enum ResourceEditServiceIdentityDecision { + Ready, + ConfirmationRequired(ResourceEditServiceIdentityConfirmation), +} + +fn resource_edit_service_identity_snapshot_sha256( + ledger: &ResourceEditLedger, +) -> Result { + resource_edit_state_sha256( + &serde_json::json!({ + "projectId": ledger.project_id, + "operationId": ledger.operation_id, + "idempotencyKey": ledger.idempotency_key, + "requestFingerprint": ledger.request_fingerprint, + "sourceSha256": ledger.source_sha256, + "phase": ledger.phase, + "endpoint": ledger.endpoint, + "requestBodySha256": ledger.request_body_json.as_deref().map(|body| sha256_hex(body.as_bytes())), + "remoteOperationId": ledger.remote_operation_id, + "remoteResourceId": ledger.remote_resource_id, + "remoteObjectKey": ledger.remote_object_key, + "sourceStableReference": ledger.source_stable_reference, + "apiIdentityScheme": ledger.api_identity_scheme, + "apiIdentityFingerprint": ledger.api_identity_fingerprint, + }), + "资源编辑服务身份确认快照", + ) +} + +fn resource_edit_has_service_identity_evidence(ledger: &ResourceEditLedger) -> bool { + ledger.endpoint.is_some() + || ledger.request_body_json.is_some() + || ledger.remote_operation_id.is_some() + || ledger.remote_resource_id.is_some() + || ledger.remote_object_key.is_some() + || ledger.source_stable_reference.is_some() + || ledger.phase != ResourceEditLedgerPhase::Prepared +} + +fn public_resource_edit_service_identity_confirmation( + ledger: &ResourceEditLedger, + service_origin: String, + confirmation: &ResourceEditPrivateServiceIdentityConfirmation, +) -> ResourceEditServiceIdentityConfirmation { + ResourceEditServiceIdentityConfirmation { + operation_id: ledger.operation_id.clone(), + remote_operation_id: ledger.remote_operation_id.clone(), + operation_state: ledger.phase.as_str().to_string(), + service_origin, + challenge: confirmation.challenge.clone(), + expires_at: confirmation.expires_at, + } +} + +fn prepare_resource_edit_service_identity( + root: &Path, + ledger: &mut ResourceEditLedger, + api_base_url: &str, + api_key: &str, +) -> Result { + let fingerprint = platform_art_generation_external_service_fingerprint(api_base_url); + match classify_platform_art_generation_service_identity( + ledger.api_identity_scheme.as_deref(), + ledger.api_identity_fingerprint.as_deref(), + api_base_url, + api_key, + ) { + PlatformArtGenerationServiceIdentityMatch::Current + | PlatformArtGenerationServiceIdentityMatch::LegacyVerified => { + if ledger.api_identity_scheme.as_deref() + != Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) + || ledger.api_identity_fingerprint.as_deref() != Some(fingerprint.as_str()) + || ledger.service_identity_confirmation.is_some() + { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(fingerprint); + ledger.service_identity_confirmation = None; + write_resource_edit_ledger(root, ledger)?; + } + Ok(ResourceEditServiceIdentityDecision::Ready) + } + PlatformArtGenerationServiceIdentityMatch::Unbound + if !resource_edit_has_service_identity_evidence(ledger) => + { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(fingerprint); + ledger.service_identity_confirmation = None; + write_resource_edit_ledger(root, ledger)?; + Ok(ResourceEditServiceIdentityDecision::Ready) + } + PlatformArtGenerationServiceIdentityMatch::LegacyUnverified + | PlatformArtGenerationServiceIdentityMatch::Unbound => { + let service_origin = platform_art_generation_external_service_origin(api_base_url)?; + let ledger_snapshot_sha256 = resource_edit_service_identity_snapshot_sha256(ledger)?; + let now = unix_timestamp(); + let confirmation_is_current = ledger + .service_identity_confirmation + .as_ref() + .is_some_and(|confirmation| { + confirmation.service_fingerprint == fingerprint + && confirmation.ledger_snapshot_sha256 == ledger_snapshot_sha256 + && confirmation.expires_at > now + }); + if !confirmation_is_current { + let expires_at = now + .checked_add(RESOURCE_EDIT_SERVICE_IDENTITY_CONFIRMATION_TTL_SECONDS) + .ok_or_else(|| "资源编辑服务身份确认有效期溢出".to_string())?; + ledger.service_identity_confirmation = + Some(ResourceEditPrivateServiceIdentityConfirmation { + challenge: Uuid::new_v4().to_string(), + service_fingerprint: fingerprint, + ledger_snapshot_sha256, + expires_at, + }); + write_resource_edit_ledger(root, ledger)?; + } + let confirmation = ledger + .service_identity_confirmation + .as_ref() + .ok_or_else(|| "资源编辑服务身份确认挑战缺失".to_string())?; + Ok(ResourceEditServiceIdentityDecision::ConfirmationRequired( + public_resource_edit_service_identity_confirmation( + ledger, + service_origin, + confirmation, + ), + )) + } + PlatformArtGenerationServiceIdentityMatch::Changed => { + ledger.service_identity_confirmation = None; + Err( + "result-unknown: External Editor 服务地址已变化,必须保留原 operation 对账" + .to_string(), + ) + } + } +} + async fn prepare_remote_resource_edit( root: &Path, input: &DeriveLocalProjectResourceInput, @@ -1832,29 +2145,11 @@ async fn prepare_remote_resource_edit( .map_err(|_| "External Editor API base URL 配置无效".to_string())?; 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_service_fingerprint(&api_base_url); - let identity_match = classify_platform_art_generation_service_identity( - ledger.api_identity_scheme.as_deref(), - ledger.api_identity_fingerprint.as_deref(), - &api_base_url, - &api_key, - ); - let can_bind_unbound = ledger.endpoint.is_none() - && ledger.request_body_json.is_none() - && ledger.remote_operation_id.is_none() - && ledger.remote_resource_id.is_none(); - if !matches!( - identity_match, - PlatformArtGenerationServiceIdentityMatch::Current - | PlatformArtGenerationServiceIdentityMatch::LegacyVerified - ) && !(identity_match == PlatformArtGenerationServiceIdentityMatch::Unbound - && can_bind_unbound) - { - return Err( - "result-unknown: External Editor 服务身份无法自动验证,必须保留原 operation 对账" - .to_string(), - ); + match prepare_resource_edit_service_identity(root, ledger, &api_base_url, &api_key)? { + ResourceEditServiceIdentityDecision::Ready => {} + ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { + return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) + } } if ledger .endpoint @@ -1865,15 +2160,6 @@ async fn prepare_remote_resource_edit( "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), ); } - if ledger.api_identity_scheme.as_deref() - != Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) - || ledger.api_identity_fingerprint.as_deref() != Some(api_identity_fingerprint.as_str()) - { - ledger.api_identity_scheme = - Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = Some(api_identity_fingerprint); - write_resource_edit_ledger(root, ledger)?; - } let client = reqwest::Client::builder() .timeout(Duration::from_secs(35 * 60)) .build() @@ -1884,43 +2170,43 @@ async fn prepare_remote_resource_edit( .clone() .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? } else { - let source_reference = if matches!( - input.edit_kind, - LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Video - ) { - Some( - ensure_resource_edit_source_reference( - root, - &client, - &api_base_url, - &api_key, - input, - source, - ledger, - ) - .await?, - ) - } else { - None - }; - if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { - let (endpoint, body) = resource_edit_remote_request( - input, - source, - prompt, - asset_name, - source_reference.as_deref(), - )?; - ledger.endpoint = Some(endpoint.to_string()); - ledger.request_body_json = Some( - serde_json::to_string(&body) - .map_err(|error| format!("序列化资源编辑生成请求失败:{error}"))?, - ); - write_resource_edit_ledger(root, ledger)?; - } let generated = if ledger.remote_operation_id.is_some() { wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? } else { + let source_reference = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Video + ) { + Some( + ensure_resource_edit_source_reference( + root, + &client, + &api_base_url, + &api_key, + input, + source, + ledger, + ) + .await?, + ) + } else { + None + }; + if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { + let (endpoint, body) = resource_edit_remote_request( + input, + source, + prompt, + asset_name, + source_reference.as_deref(), + )?; + ledger.endpoint = Some(endpoint.to_string()); + ledger.request_body_json = Some( + serde_json::to_string(&body) + .map_err(|error| format!("序列化资源编辑生成请求失败:{error}"))?, + ); + write_resource_edit_ledger(root, ledger)?; + } let submission = submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { @@ -1973,6 +2259,7 @@ fn cleanup_committed_resource_edit_staging( ) -> Result<(), String> { let Some(staged_bytes) = read_optional_resource_edit_staging(root, &ledger.operation_id)? else { + let _ = remove_resource_edit_provider_handoff(root, &ledger.operation_id); return Ok(()); }; let Some(mut journal) = read_resource_edit_asset_journal(root, &ledger.operation_id)? else { @@ -2036,6 +2323,7 @@ fn cleanup_committed_resource_edit_staging( )); } let _ = remove_resource_edit_staging(root, &ledger.operation_id); + let _ = remove_resource_edit_provider_handoff(root, &ledger.operation_id); Ok(()) } @@ -2539,6 +2827,34 @@ fn resource_edit_final_media_matches( } } +fn resource_edit_asset_is_exactly_present( + manifest: &GameCreationAppManifest, + journal: &ResourceEditAssetJournal, +) -> bool { + let matching_assets = manifest + .assets + .iter() + .filter(|entry| { + entry.id == journal.asset.id || entry.local_path == journal.asset.local_path + }) + .collect::>(); + matching_assets.len() == 1 && matching_assets[0] == &journal.asset +} + +fn resource_edit_asset_commit_is_forward_proven( + manifest: &GameCreationAppManifest, + revision: &AgentRuntimeProjectRevision, + journal: &ResourceEditAssetJournal, + media_matches: Option, +) -> bool { + matches!( + journal.phase, + ResourceEditAssetJournalPhase::RevisionWritten | ResourceEditAssetJournalPhase::Committed + ) && media_matches == Some(true) + && revision.revision >= journal.target_project_revision + && resource_edit_asset_is_exactly_present(manifest, journal) +} + fn install_resource_edit_final_media( root: &Path, relative_path: &str, @@ -2749,8 +3065,14 @@ fn commit_resource_edit_asset_internal( let revision_is_before = current_revision_sha == journal.project_revision_before_sha256; let revision_is_after = current_revision_sha == journal.project_revision_after_sha256; + let forward_commit_is_proven = resource_edit_asset_commit_is_forward_proven( + &manifest, + ¤t_revision, + &journal, + media_matches, + ); if journal.phase == ResourceEditAssetJournalPhase::Committed { - if !manifest_is_after || !revision_is_after || media_matches != Some(true) { + if !forward_commit_is_proven { return Err(mark_resource_edit_asset_reconciliation( root, Some(&mut journal), @@ -2824,7 +3146,9 @@ fn commit_resource_edit_asset_internal( fault, ResourceEditAssetCommitFaultStage::RevisionWritten, )?; - } else if !(manifest_is_after && revision_is_after && media_matches == Some(true)) { + } else if !(manifest_is_after && revision_is_after && media_matches == Some(true)) + && !forward_commit_is_proven + { return Err(mark_resource_edit_asset_reconciliation( root, Some(&mut journal), @@ -2835,16 +3159,25 @@ fn commit_resource_edit_asset_internal( let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?; - if resource_edit_state_sha256(&manifest, "提交后 manifest")? != journal.manifest_after_sha256 - || resource_edit_state_sha256(&revision, "提交后项目 revision")? - != journal.project_revision_after_sha256 - || resource_edit_final_media_matches(root, &journal)? != Some(true) - || manifest + let media_matches = resource_edit_final_media_matches(root, &journal)?; + let exact_snapshot_is_proven = resource_edit_state_sha256(&manifest, "提交后 manifest")? + == journal.manifest_after_sha256 + && resource_edit_state_sha256(&revision, "提交后项目 revision")? + == journal.project_revision_after_sha256 + && media_matches == Some(true) + && manifest .assets .iter() .filter(|entry| entry.id == asset_id) .count() - != 1 + == 1; + if !exact_snapshot_is_proven + && !resource_edit_asset_commit_is_forward_proven( + &manifest, + &revision, + &journal, + media_matches, + ) { return Err(mark_resource_edit_asset_reconciliation( root, @@ -2870,6 +3203,7 @@ fn commit_resource_edit_asset_internal( )?; if fault != Some(ResourceEditAssetCommitFaultStage::StagingCleanupFailed) { let _ = remove_resource_edit_staging(root, &input.operation_id); + let _ = remove_resource_edit_provider_handoff(root, &input.operation_id); } Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), @@ -3241,6 +3575,117 @@ pub(crate) fn list_pending_local_project_resource_edits_at( Ok(pending) } +pub(crate) async fn request_resource_edit_service_identity_confirmation_at( + input: RequestResourceEditServiceIdentityConfirmationInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId")?; + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + let _operation_guard = operation_lock.lock().await; + let _project_write_lock = acquire_project_write_lock(root, "resource.edit.service-identity")?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let mut 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 + || !ledger.edit_kind.is_remote_media() + { + return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string()); + } + ensure_resource_edit_phase_resumable(&ledger.phase)?; + let api_base_url = resolve_canvas_sync_api_base_url(None) + .map_err(|_| "External Editor API base URL 配置无效".to_string())?; + let api_key = resolve_canvas_sync_api_key(None) + .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + match prepare_resource_edit_service_identity(root, &mut ledger, &api_base_url, &api_key)? { + ResourceEditServiceIdentityDecision::ConfirmationRequired(confirmation) => Ok(confirmation), + ResourceEditServiceIdentityDecision::Ready => { + Err("当前资源编辑 operation 的服务身份已验证,无需显式确认".to_string()) + } + } +} + +pub(crate) async fn confirm_resource_edit_service_identity_at( + input: ConfirmResourceEditServiceIdentityInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId")?; + if input.challenge.len() < 32 + || input.challenge.len() > 128 + || input.challenge.chars().any(char::is_control) + || input.remote_operation_id.as_ref().is_some_and(|value| { + value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) + }) + { + return Err("资源编辑服务身份确认参数无效".to_string()); + } + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + let _operation_guard = operation_lock.lock().await; + let _project_write_lock = acquire_project_write_lock(root, "resource.edit.service-identity")?; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let api_base_url = resolve_canvas_sync_api_base_url(None) + .map_err(|_| "External Editor API base URL 配置无效".to_string())?; + let api_key = resolve_canvas_sync_api_key(None) + .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let service_fingerprint = platform_art_generation_external_service_fingerprint(&api_base_url); + let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; + let mut 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 + || ledger.remote_operation_id != input.remote_operation_id + || !ledger.edit_kind.is_remote_media() + { + return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string()); + } + ensure_resource_edit_phase_resumable(&ledger.phase)?; + let confirmation = ledger + .service_identity_confirmation + .clone() + .ok_or_else(|| "资源编辑服务身份确认挑战不存在或已失效".to_string())?; + if confirmation.challenge != input.challenge + || confirmation.expires_at <= unix_timestamp() + || confirmation.service_fingerprint != service_fingerprint + || confirmation.ledger_snapshot_sha256 + != resource_edit_service_identity_snapshot_sha256(&ledger)? + { + return Err("资源编辑服务身份确认挑战已过期或上下文已变化".to_string()); + } + if !matches!( + classify_platform_art_generation_service_identity( + ledger.api_identity_scheme.as_deref(), + ledger.api_identity_fingerprint.as_deref(), + &api_base_url, + &api_key, + ), + PlatformArtGenerationServiceIdentityMatch::LegacyUnverified + | PlatformArtGenerationServiceIdentityMatch::Unbound + ) { + return Err("资源编辑服务身份确认目标不再是待确认旧账本".to_string()); + } + ledger.api_identity_scheme = Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(service_fingerprint); + ledger.service_identity_confirmation = None; + write_resource_edit_ledger(root, &ledger)?; + Ok(ConfirmResourceEditServiceIdentityResult { + operation_id: ledger.operation_id, + remote_operation_id: ledger.remote_operation_id, + operation_state: ledger.phase.as_str().to_string(), + service_origin, + identity_scheme: PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string(), + }) +} + pub(crate) async fn archive_failed_local_project_resource_edit_at( input: ArchiveFailedLocalProjectResourceEditInput, ) -> Result { @@ -3439,8 +3884,10 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + service_identity_confirmation: None, phase: ResourceEditLedgerPhase::Prepared, endpoint: None, request_body_json: None, @@ -3485,18 +3932,30 @@ pub(crate) async fn derive_local_project_resource_at( if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; - let bytes = match staged { - Some(bytes) => bytes, - None => generate_resource_edit_text(&source, &input, &prompt).await?, + let generated = match staged { + Some(bytes) => Ok(bytes), + None => generate_resource_edit_text(root, &source, &input, &prompt, &ledger).await, }; - let content = - std::str::from_utf8(&bytes).map_err(|_| "派生文本不是 UTF-8".to_string())?; - let (media_type, extension) = - validate_text_derivative(&input.edit_kind, source.source_path.as_deref(), content)?; - write_resource_edit_staging(root, &input.operation_id, &bytes)?; - ledger.staged_media_type = Some(media_type); - ledger.staged_extension = Some(extension); - update_resource_edit_phase(root, &mut ledger, ResourceEditLedgerPhase::MediaDownloaded) + match generated { + Err(error) => Err(error), + Ok(bytes) => (|| { + let content = std::str::from_utf8(&bytes) + .map_err(|_| "派生文本不是 UTF-8".to_string())?; + let (media_type, extension) = validate_text_derivative( + &input.edit_kind, + source.source_path.as_deref(), + content, + )?; + write_resource_edit_staging(root, &input.operation_id, &bytes)?; + ledger.staged_media_type = Some(media_type); + ledger.staged_extension = Some(extension); + update_resource_edit_phase( + root, + &mut ledger, + ResourceEditLedgerPhase::MediaDownloaded, + ) + })(), + } } else if input.edit_kind.is_remote_media() { prepare_remote_resource_edit(root, &input, &source, &prompt, &asset_name, &mut ledger) .await @@ -3504,8 +3963,9 @@ pub(crate) async fn derive_local_project_resource_at( Err("当前资源类型没有编辑实现".to_string()) }; if let Err(error) = generation_result { - if ledger.phase != ResourceEditLedgerPhase::RemoteCompleted - && (error.contains("result-unknown") || error.contains("authentication-required")) + if ledger.remote_operation_id.is_none() + && ledger.phase != ResourceEditLedgerPhase::RemoteCompleted + && error.contains("result-unknown") { update_resource_edit_phase( root, @@ -3645,8 +4105,10 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + service_identity_confirmation: None, phase, endpoint: None, request_body_json: None, @@ -4015,6 +4477,33 @@ mod tests { .expect_err("authentication status must fail"); server.join().expect("join auth fixture"); assert!(error.contains("authentication-required")); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Prepared, "{status}"); + assert!(ledger.remote_operation_id.is_none(), "{status}"); + } + + ledger.remote_operation_id = Some("remote-one".to_string()); + update_resource_edit_phase(root, &mut ledger, ResourceEditLedgerPhase::Accepted) + .expect("persist accepted operation"); + for status in ["401 Unauthorized", "403 Forbidden"] { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind poll auth fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("poll auth address") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept poll auth request"); + let request = read_http_request(&mut stream); + assert!(request.starts_with("GET /api/external/v1/generations/remote-one ")); + write_json(&mut stream, status, serde_json::json!({"error": "denied"})); + }); + let error = + wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) + .await + .expect_err("poll authentication status must fail"); + server.join().expect("join poll auth fixture"); + assert!(error.contains("authentication-required")); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted, "{status}"); + assert_eq!(ledger.remote_operation_id.as_deref(), Some("remote-one")); } let listener = TcpListener::bind("127.0.0.1:0").expect("bind missing status fixture"); @@ -4031,13 +4520,13 @@ mod tests { serde_json::json!({"error": "missing"}), ); }); - ledger.remote_operation_id = Some("remote-one".to_string()); let error = wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) .await .expect_err("missing remote status must stay unknown"); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); let operations = fs::read_dir(root.join(format!("{RESOURCE_EDIT_ROOT}/operations"))) .expect("read operations") @@ -4053,6 +4542,174 @@ mod tests { ); } + #[tokio::test] + async fn legacy_service_identity_confirmation_is_snapshot_bound_and_resumes_with_get_only() { + let directory = tempfile::tempdir().expect("create service identity fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑服务身份确认测试") + .expect("initialize project"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind service identity server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("service identity address") + ); + let new_api_key = "rotated-resource-editor-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": base_url, + "apiKey": new_api_key + }}) + .to_string(), + ); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:legacy-video".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/legacy-video.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::Accepted); + ledger.api_identity_fingerprint = Some( + platform_art_generation_legacy_external_configuration_fingerprint( + &base_url, + "retired-resource-editor-key", + ), + ); + ledger.endpoint = Some("/api/external/v1/editor/videos/generations".to_string()); + ledger.request_body_json = Some("{\"prompt\":\"frozen\"}".to_string()); + ledger.remote_operation_id = Some("legacy-remote-operation".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write legacy ledger"); + + let first = request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect("issue service identity challenge"); + assert_eq!( + first.remote_operation_id.as_deref(), + Some("legacy-remote-operation") + ); + assert_eq!(first.service_origin, base_url); + + let mut changed = read_resource_edit_ledger(root, &request.operation_id) + .expect("read challenged ledger") + .expect("challenged ledger"); + changed.request_body_json = Some("{\"prompt\":\"changed\"}".to_string()); + write_resource_edit_ledger(root, &changed).expect("change challenged snapshot"); + let stale_error = + confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + remote_operation_id: Some("legacy-remote-operation".to_string()), + challenge: first.challenge, + }) + .await + .expect_err("changed ledger must invalidate challenge"); + assert!(stale_error.contains("上下文已变化"), "{stale_error}"); + + let second = request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect("rotate stale challenge"); + let mut expired = read_resource_edit_ledger(root, &request.operation_id) + .expect("read second challenged ledger") + .expect("second challenged ledger"); + expired + .service_identity_confirmation + .as_mut() + .expect("second private challenge") + .expires_at = unix_timestamp(); + write_resource_edit_ledger(root, &expired).expect("expire service identity challenge"); + let expired_error = + confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + remote_operation_id: Some("legacy-remote-operation".to_string()), + challenge: second.challenge, + }) + .await + .expect_err("expired challenge must fail closed"); + assert!(expired_error.contains("已过期"), "{expired_error}"); + let current = request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect("rotate expired challenge"); + let confirmed = + confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + remote_operation_id: Some("legacy-remote-operation".to_string()), + challenge: current.challenge, + }) + .await + .expect("confirm current service identity"); + assert_eq!( + confirmed.identity_scheme, + PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME + ); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept resumed poll"); + let request = read_http_request(&mut stream); + assert!( + request.starts_with("GET /api/external/v1/generations/legacy-remote-operation ") + ); + assert!(!request.starts_with("POST ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer rotated-resource-editor-key")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"job": { + "status": "completed", + "result": {"resource": {"objectKey": "generated/legacy-result.mp4"}} + }}}), + ); + }); + let mut resumed = read_resource_edit_ledger(root, &request.operation_id) + .expect("read confirmed ledger") + .expect("confirmed ledger"); + wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + new_api_key, + &mut resumed, + ) + .await + .expect("resume accepted operation by GET"); + server.join().expect("join resumed poll server"); + assert_eq!(resumed.phase, ResourceEditLedgerPhase::Accepted); + } + #[tokio::test] async fn submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation() { let directory = tempfile::tempdir().expect("create submission status fixture"); @@ -5042,6 +5699,203 @@ mod tests { ); } + #[tokio::test] + async fn text_provider_handoff_recovers_without_network_and_commits_once() { + let directory = tempfile::tempdir().expect("create text handoff fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "文本 Provider 交接恢复测试") + .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 source 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("resolve source"); + let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) + .expect("normalize prompt"); + let asset_name = normalize_resource_edit_name(&request.asset_name).expect("normalize name"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.request_fingerprint = + resource_edit_request_fingerprint(&request, &source, &prompt, &asset_name) + .expect("request fingerprint"); + write_resource_edit_ledger(root, &ledger).expect("write prepared ledger"); + write_resource_edit_provider_handoff( + root, + &ledger, + serde_json::json!({"content": "# Original rules\n\nRed hair\n"}).to_string(), + ) + .expect("persist successful Provider response before parsing"); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read pre-recovery staging") + .is_none() + ); + + let first = derive_local_project_resource_at(request.clone()) + .await + .expect("recover solely from Provider handoff"); + let first_asset = first.asset.expect("derived asset"); + assert_eq!( + fs::read(root.join(&first_asset.local_path)).expect("read derived text"), + b"# Original rules\n\nRed hair\n" + ); + assert!(read_resource_edit_provider_handoff(root, &ledger) + .expect("read cleaned handoff") + .is_none()); + + let replay = derive_local_project_resource_at(request.clone()) + .await + .expect("replay committed text operation"); + assert_eq!( + replay + .manifest + .assets + .iter() + .filter(|asset| asset.id == first_asset.id) + .count(), + 1 + ); + assert_eq!( + replay.asset.as_ref().map(|asset| asset.id.as_str()), + Some(first_asset.id.as_str()) + ); + } + + #[tokio::test] + async fn corrupt_text_provider_handoff_fails_closed_without_recalling_provider() { + let directory = tempfile::tempdir().expect("create corrupt handoff fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "文本 Provider 交接损坏测试") + .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 source 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("resolve source"); + let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) + .expect("normalize prompt"); + let asset_name = normalize_resource_edit_name(&request.asset_name).expect("normalize name"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.request_fingerprint = + resource_edit_request_fingerprint(&request, &source, &prompt, &asset_name) + .expect("request fingerprint"); + write_resource_edit_ledger(root, &ledger).expect("write prepared ledger"); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_provider_handoff_path(&request.operation_id), + "损坏的资源编辑 Provider handoff", + &ResourceEditProviderHandoff { + schema_version: RESOURCE_EDIT_PROVIDER_HANDOFF_SCHEMA_VERSION.to_string(), + operation_id: request.operation_id.clone(), + request_fingerprint: ledger.request_fingerprint.clone(), + source_sha256: ledger.source_sha256.clone(), + response_sha256: "0".repeat(64), + response_text: serde_json::json!({"content": "must not be consumed"}).to_string(), + created_at: unix_timestamp(), + }, + RESOURCE_EDIT_PROVIDER_HANDOFF_MAX_BYTES, + ) + .expect("write corrupt handoff fixture"); + + let error = derive_local_project_resource_at(request.clone()) + .await + .expect_err("corrupt durable handoff must fail closed before Provider"); + assert!(error.contains("result-unknown"), "{error}"); + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read reconciled ledger") + .expect("reconciled ledger"); + assert_eq!( + persisted.phase, + ResourceEditLedgerPhase::ReconciliationRequired + ); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read absent staging") + .is_none() + ); + } + + #[tokio::test] + async fn issued_text_provider_request_without_handoff_requires_reconciliation() { + let directory = tempfile::tempdir().expect("create issued request fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "文本 Provider issued 恢复测试") + .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 source 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("resolve source"); + let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) + .expect("normalize prompt"); + let asset_name = normalize_resource_edit_name(&request.asset_name).expect("normalize name"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.request_fingerprint = + resource_edit_request_fingerprint(&request, &source, &prompt, &asset_name) + .expect("request fingerprint"); + ledger.provider_request_issued_at = Some(unix_timestamp()); + write_resource_edit_ledger(root, &ledger).expect("write issued ledger"); + + let error = derive_local_project_resource_at(request.clone()) + .await + .expect_err("issued request without handoff must not recall Provider"); + assert!(error.contains("禁止重复调用"), "{error}"); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read reconciled ledger") + .expect("reconciled ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired + ); + } + #[test] fn asset_commit_recovers_every_durable_journal_crash_stage() { for fault in [ @@ -5142,6 +5996,178 @@ mod tests { } } + #[test] + fn revision_written_or_journal_committed_recovery_accepts_later_project_progress() { + for fault in [ + ResourceEditAssetCommitFaultStage::RevisionWritten, + ResourceEditAssetCommitFaultStage::JournalCommitted, + ] { + let directory = tempfile::tempdir().expect("create forward recovery 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("resolve 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_ledger(root, &ledger).expect("write pending ledger"); + write_resource_edit_staging(root, &request.operation_id, b"# Derived\n") + .expect("stage derivative"); + commit_resource_edit_asset_internal( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + Some(fault), + ) + .expect_err("fault must stop before ledger commit"); + + let later = upload_local_asset_at( + root, + &format!("later-{fault:?}.txt"), + "text/plain", + b"later mutation\n", + ) + .expect("apply later legitimate project mutation"); + advance_agent_runtime_project_revision_locked(root) + .expect("advance revision for later legitimate mutation"); + let progressed_revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read progressed revision") + .revision; + assert!(progressed_revision > 1); + + let recovered = commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + .expect("complete ledger after later project progress"); + assert_eq!(recovered.committed_project_revision, progressed_revision); + assert!(recovered + .manifest + .assets + .iter() + .any(|asset| asset.id == later.id)); + assert_eq!( + recovered + .manifest + .assets + .iter() + .filter(|asset| asset.id == format!("edit-{}", request.operation_id)) + .count(), + 1 + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read recovered ledger") + .expect("recovered ledger") + .phase, + ResourceEditLedgerPhase::Committed + ); + } + } + + #[test] + fn revision_written_recovery_rejects_conflicting_asset_after_revision_progress() { + let directory = tempfile::tempdir().expect("create forward 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("resolve 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_ledger(root, &ledger).expect("write pending ledger"); + write_resource_edit_staging(root, &request.operation_id, b"# Derived\n") + .expect("stage derivative"); + commit_resource_edit_asset_internal( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + Some(ResourceEditAssetCommitFaultStage::RevisionWritten), + ) + .expect_err("fault must stop after revision"); + + let asset_id = format!("edit-{}", request.operation_id); + let mut conflicted = + read_existing_manifest_for_project(root).expect("read installed asset"); + conflicted + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .expect("installed derivative") + .media_type = "text/plain".to_string(); + write_manifest(&root.join(".agent/manifest.json"), &conflicted) + .expect("write conflicting manifest"); + advance_agent_runtime_project_revision_locked(root).expect("advance conflicting revision"); + + let error = commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + .expect_err("conflicting derivative must fail closed"); + assert!(error.contains("reconciliation-required"), "{error}"); + assert_eq!( + read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read conflicted journal") + .expect("conflicted journal") + .phase, + ResourceEditAssetJournalPhase::ReconciliationRequired + ); + } + #[tokio::test] async fn committed_ledger_replay_cleans_matching_staging_after_crash() { let directory = tempfile::tempdir().expect("create committed staging fixture"); 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 f947df7e7..3fc6544b7 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 @@ -532,12 +532,19 @@ export function AssetCanvasSurface({ const generationStartingRef = useRef(false); const generationFocusEpochRef = useRef(0); const generationStopButtonRef = useRef(null); + const modalInitialFocusRef = useRef(null); + const generationDialogRef = useRef(generationDialog); + const exitDialogOpenRef = useRef(exitDialogOpen); + const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen); + const modalOpen = + generationDialog !== null || exitDialogOpen || serviceIdentityDialogOpen; + const backgroundInteractionLocked = + lifecycle.kind !== 'canvas.editing' || modalOpen; + const backgroundInteractionLockedRef = useRef(backgroundInteractionLocked); const onWalletBalanceMayHaveChanged = useWalletStore( (state) => state.onWalletBalanceMayHaveChanged, ); - const assetSettingsDisabled = - lifecycle.kind === 'canvas.saving' || - lifecycle.kind === 'canvas.generating'; + const assetSettingsDisabled = backgroundInteractionLocked; const inheritedUnknownAssetKind = stableScope.intent === 'refine' && !ASSET_CANVAS_KIND_OPTIONS.some((option) => option.value === assetKind); @@ -548,10 +555,14 @@ export function AssetCanvasSurface({ selectionRef.current = selectedLayerIds; draftRef.current = draft; lifecycleRef.current = lifecycle; + generationDialogRef.current = generationDialog; + exitDialogOpenRef.current = exitDialogOpen; + serviceIdentityDialogOpenRef.current = serviceIdentityDialogOpen; + backgroundInteractionLockedRef.current = backgroundInteractionLocked; documentVersionRef.current = documentVersion; const markDirty = useCallback(() => { - if (lifecycleRef.current.kind !== 'canvas.editing') return; + if (backgroundInteractionLockedRef.current) return; setDocumentVersion((value) => { const next = value + 1; documentVersionRef.current = next; @@ -650,9 +661,11 @@ export function AssetCanvasSurface({ [captureCanvasHistory], ); const undo = useCallback(() => { + if (backgroundInteractionLockedRef.current) return; if (undoCanvasChange().status === 'success') markDirty(); }, [markDirty, undoCanvasChange]); const redo = useCallback(() => { + if (backgroundInteractionLockedRef.current) return; if (redoCanvasChange().status === 'success') markDirty(); }, [markDirty, redoCanvasChange]); @@ -713,7 +726,6 @@ export function AssetCanvasSurface({ documentVersionRef.current = 0; persistedDocumentVersionRef.current = 0; setDocumentVersion(0); - setLifecycle({ kind: 'canvas.editing', dirty: false }); }, [host, resetCanvasHistory, stableScope], ); @@ -811,55 +823,53 @@ 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 - ) { - applyGenerationProgressRevision(progress); - setNotice(`正在恢复图片生成:${progress.phase}`); - } - }, - }) - .then((result) => { + const result = await host.generation.recoverImages({ + scope: stableScope, + onProgress: (progress) => { if ( - epoch !== epochRef.current || - recoveryFocusEpoch !== generationFocusEpochRef.current + epoch === epochRef.current && + recoveryFocusEpoch === generationFocusEpochRef.current ) { - return; + applyGenerationProgressRevision(progress); + setNotice(`正在恢复图片生成:${progress.phase}`); } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'recovery', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '原生成任务恢复发生冲突', - reconciliationRequired: - result.status === 'failed' && - result.code === 'reconciliation-required', - }); - return; - } - if (result.value.resumedGenerationIds.length) { - setNotice( - `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, - ); - } - setServiceIdentityConfirmations( - result.value.serviceIdentityConfirmations, - ); - setServiceIdentityDialogOpen( - result.value.serviceIdentityConfirmations.length > 0, - ); - setServiceIdentityError(''); - void onWalletBalanceMayHaveChanged(); + }, + }); + if ( + epoch !== epochRef.current || + recoveryFocusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '原生成任务恢复发生冲突', + reconciliationRequired: + result.status === 'failed' && + result.code === 'reconciliation-required', }); + return; + } + if (result.value.resumedGenerationIds.length) { + setNotice( + `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, + ); + } + setServiceIdentityConfirmations( + result.value.serviceIdentityConfirmations, + ); + setServiceIdentityDialogOpen( + result.value.serviceIdentityConfirmations.length > 0, + ); + setServiceIdentityError(''); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + void onWalletBalanceMayHaveChanged(); })().catch((error: unknown) => { if ( epoch === epochRef.current && @@ -897,7 +907,16 @@ export function AssetCanvasSurface({ const confirmCurrentGenerationServiceIdentity = useCallback(async () => { const confirmation = serviceIdentityConfirmations[0]; - if (!confirmation || serviceIdentityPending) return; + if ( + !confirmation || + serviceIdentityPending || + lifecycleRef.current.kind !== 'canvas.editing' || + !serviceIdentityDialogOpenRef.current || + generationDialogRef.current !== null || + exitDialogOpenRef.current + ) { + return; + } const epoch = epochRef.current; const focusEpoch = generationFocusEpochRef.current; setServiceIdentityPending(true); @@ -1056,7 +1075,7 @@ export function AssetCanvasSurface({ useEffect(() => { const onMove = (event: PointerEvent) => { - if (lifecycleRef.current.kind !== 'canvas.editing') { + if (backgroundInteractionLockedRef.current) { dragRef.current = null; return; } @@ -1147,7 +1166,7 @@ export function AssetCanvasSurface({ } }; const onUp = (event: PointerEvent) => { - if (lifecycleRef.current.kind !== 'canvas.editing') { + if (backgroundInteractionLockedRef.current) { dragRef.current = null; return; } @@ -1178,10 +1197,12 @@ export function AssetCanvasSurface({ const currentDraft = draftRef.current; if ( lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || !currentDraft || !files.length ) return; + const epoch = epochRef.current; const images = await Promise.all( files.map(async (file) => { const mediaType = mediaTypeForFile(file); @@ -1193,13 +1214,23 @@ export function AssetCanvasSurface({ }; }), ); - const epoch = epochRef.current; + if ( + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { + return; + } const imported = await host.asset.importImages({ scope: stableScope, expectedDraftRevision: currentDraft.revision, images, }); - if (epoch !== epochRef.current) { + if ( + epoch !== epochRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current + ) { if (imported.status === 'ok') { for (const image of imported.value) URL.revokeObjectURL(image.previewUrl); @@ -1269,6 +1300,7 @@ export function AssetCanvasSurface({ const deleteSelected = useCallback(() => { if ( lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || !selectionRef.current.length ) return; @@ -1289,7 +1321,13 @@ export function AssetCanvasSurface({ }, [captureHistory, markDirty]); const saveAsset = useCallback(() => { - if (savePromiseRef.current) return savePromiseRef.current; + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + savePromiseRef.current + ) { + return savePromiseRef.current ?? undefined; + } const saveEpoch = epochRef.current; const pending = pendingCommitRef.current?.documentVersion === documentVersion @@ -1430,7 +1468,15 @@ export function AssetCanvasSurface({ const discardCanvas = useCallback(() => { const currentDraft = draftRef.current; - if (!currentDraft || lifecycleRef.current.kind === 'canvas.saving') { + if ( + !currentDraft || + !['canvas.editing', 'canvas.failed'].includes( + lifecycleRef.current.kind, + ) || + !exitDialogOpenRef.current || + generationDialogRef.current !== null || + serviceIdentityDialogOpenRef.current + ) { return; } setExitActionPending(true); @@ -1460,6 +1506,7 @@ export function AssetCanvasSurface({ : '素材画布取消发生 revision 冲突', reconciliationRequired: false, }); + setExitDialogOpen(false); }) .catch((error: unknown) => { if (epoch !== epochRef.current) return; @@ -1470,6 +1517,7 @@ export function AssetCanvasSurface({ message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, }); + setExitDialogOpen(false); }) .finally(() => { if (epoch === epochRef.current) setExitActionPending(false); @@ -1477,7 +1525,17 @@ export function AssetCanvasSurface({ }, [host.project, onCancel, stableScope]); const keepDraftAndExit = useCallback(() => { - if (exitActionPending) return; + if ( + exitActionPending || + !['canvas.editing', 'canvas.failed'].includes( + lifecycleRef.current.kind, + ) || + !exitDialogOpenRef.current || + generationDialogRef.current !== null || + serviceIdentityDialogOpenRef.current + ) { + return; + } const currentDraft = draftRef.current; if (!currentDraft) return; setExitActionPending(true); @@ -1530,8 +1588,8 @@ export function AssetCanvasSurface({ const currentDraft = draftRef.current; if ( !currentDraft || - lifecycleRef.current.kind === 'canvas.saving' || - lifecycleRef.current.kind === 'canvas.generating' + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current ) { return; } @@ -1565,6 +1623,14 @@ export function AssetCanvasSurface({ }, []); const showGenerationConfirmation = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + generationDialogRef.current !== 'edit' || + exitDialogOpenRef.current || + serviceIdentityDialogOpenRef.current + ) { + return; + } if (!generationPrompt.trim()) { setNotice('请先填写图片提示词'); return; @@ -1573,7 +1639,15 @@ export function AssetCanvasSurface({ }, [generationPrompt]); const confirmGeneration = useCallback(() => { - if (generationStartingRef.current) return; + if ( + generationStartingRef.current || + lifecycleRef.current.kind !== 'canvas.editing' || + generationDialogRef.current !== 'confirm' || + exitDialogOpenRef.current || + serviceIdentityDialogOpenRef.current + ) { + return; + } const identity = pendingGenerationRef.current; const initialDraft = draftRef.current; const prompt = generationPrompt.trim(); @@ -1829,6 +1903,12 @@ export function AssetCanvasSurface({ } }, [generationInteractionLocked]); + useEffect(() => { + if (modalOpen) { + modalInitialFocusRef.current?.focus(); + } + }, [generationDialog, modalOpen, serviceIdentityDialogOpen]); + const failurePresentation = lifecycle.kind === 'canvas.failed' ? assetCanvasFailurePresentation(lifecycle) @@ -1861,7 +1941,10 @@ export function AssetCanvasSurface({ data-session-id={sessionId} aria-label="素材创作无限画布" > -
+
{lifecycle.kind === 'canvas.generating' ? '停止等待' : '返回'} @@ -1889,10 +1972,7 @@ export function AssetCanvasSurface({ title="导入本地图片" icon={
@@ -2048,6 +2118,7 @@ export function AssetCanvasSurface({ {generationDialog === 'edit' ? 'AI 图片生成' : '确认图片生成'}