From ea8e74dc2e923b00f860494748c9a625ea902a5d Mon Sep 17 00:00:00 2001 From: menghao Date: Mon, 10 Aug 2026 11:56:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=94=BB=E5=B8=83=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E7=8E=B0=E6=9C=89=E5=9B=BE=E7=89=87=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8E=9F=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 禁用资源新增入口并将现有图片资源接入编辑画布 提示词精修生成新图片并保留原图、原素材与来源血缘 同步生成阶段草稿版本并支持鉴权失败后的原操作恢复 补齐草稿退出确认、键盘可访问性、回归测试与工程文档 --- .../src-tauri/src/project/asset_canvas.rs | 39 +++ .../src/project/asset_canvas/generation.rs | 239 +++++++++++++-- .../asset-canvas/AssetCanvasSurface.tsx | 280 +++++++++++++++--- .../asset-canvas/assetCanvasSurface.css | 18 ++ .../tauriImageCanvasHostAdapter.ts | 1 + .../src/view/project-development/index.tsx | 58 ++-- .../tests/assetCanvasSurface.test.tsx | 137 ++++++++- .../projectResourceLiveIntegration.test.tsx | 35 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 6 +- .../shared-memory/decision-log.md | 8 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- ...€‘客户端素材创作无限画布阶段一合同-2026-08-05.md | 19 +- packages/image-canvas-core/src/ports.ts | 1 + 13 files changed, 730 insertions(+), 113 deletions(-) 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 49f07b40a..9506ffdc7 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 @@ -1513,6 +1513,45 @@ fn stage_asset_canvas_image_with_token_at( }) } +fn rebind_asset_canvas_staged_image_revision_at( + root: &Path, + staged_image_token: &str, + expected_project_id: &str, + draft_id: &str, + expected_previous_revision: u64, + next_revision: u64, +) -> Result<(), String> { + validate_uuid_v4(staged_image_token, "stagedImageToken")?; + validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; + validate_safe_revision(next_revision, "staging 新草稿 revision")?; + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != next_revision { + return Err("staging 不能绑定到非当前草稿 revision".to_string()); + } + let (mut metadata, _) = read_staged_image_locked(root, staged_image_token)?; + if metadata.project_id != manifest.project_id || metadata.draft_id != draft_id { + return Err("staging 图片与当前项目草稿身份不匹配".to_string()); + } + if metadata.draft_revision == next_revision { + return Ok(()); + } + if metadata.draft_revision != expected_previous_revision { + return Err("staging 图片 revision 已被其它流程推进".to_string()); + } + metadata.draft_revision = next_revision; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{staged_image_token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + ) +} + fn draft_media_relative_path( draft_id: &str, media_ref: &AssetCanvasMediaRef, 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 ad8f041ca..38771ce38 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 @@ -54,6 +54,7 @@ pub(crate) struct AssetCanvasGenerationProgressEvent { pub(crate) draft_id: String, pub(crate) intent_id: String, pub(crate) generation_id: String, + pub(crate) draft_revision: u64, pub(crate) phase: String, pub(crate) progress: Option, pub(crate) error_code: Option, @@ -191,6 +192,8 @@ struct AssetCanvasGenerationLedger { commit_idempotency_key: String, expected_host_revision: u64, expected_draft_revision: u64, + #[serde(default)] + current_draft_revision: Option, prompt: String, aspect_ratio: String, image_size: String, @@ -210,6 +213,8 @@ struct AssetCanvasGenerationLedger { poll_after_ms: Option, remote_result: Option, staged_image_token: String, + #[serde(default)] + staged_draft_revision: Option, commit_result: Option, error_code: Option, created_at: u64, @@ -332,6 +337,9 @@ fn generation_progress_event( draft_id: ledger.draft_id.clone(), intent_id: ledger.intent_id.clone(), generation_id: ledger.generation_id.clone(), + draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), phase: phase.to_string(), progress, error_code, @@ -402,6 +410,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() } validate_safe_revision(ledger.expected_host_revision, "expectedHostRevision")?; validate_safe_revision(ledger.expected_draft_revision, "expectedDraftRevision")?; + if let Some(revision) = ledger.current_draft_revision { + validate_safe_revision(revision, "currentDraftRevision")?; + if revision < ledger.expected_draft_revision { + return Err("素材画布私有生成账本草稿 revision 倒退".to_string()); + } + } + if let Some(revision) = ledger.staged_draft_revision { + validate_safe_revision(revision, "stagedDraftRevision")?; + } if ledger.prompt.is_empty() || ledger.prompt.chars().count() > 32_000 || ledger.request_fingerprint.len() != 64 @@ -484,7 +501,7 @@ fn set_private_phase( fn upsert_public_generation_record( root: &Path, ledger: &AssetCanvasGenerationLedger, -) -> Result { +) -> Result<(AssetCanvasGenerationRecord, u64), String> { let phase = public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?; let _lock = acquire_asset_canvas_draft_lock(root)?; @@ -541,17 +558,38 @@ fn upsert_public_generation_record( } else if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted { draft.status = AssetCanvasDraftStatus::Generating; } + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; draft.updated_at = now; write_asset_canvas_draft_locked(root, &draft)?; - Ok(record) + Ok((record, draft.revision)) } fn publish_public_phase( root: &Path, - ledger: &AssetCanvasGenerationLedger, + ledger: &mut AssetCanvasGenerationLedger, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result { - let record = upsert_public_generation_record(root, ledger)?; + synchronize_staged_image_revision(root, ledger)?; + let previous_staged_revision = ledger.staged_draft_revision; + let (record, draft_revision) = upsert_public_generation_record(root, ledger)?; + ledger.current_draft_revision = Some(draft_revision); + write_generation_ledger(root, ledger)?; + if let Some(previous_revision) = previous_staged_revision { + rebind_asset_canvas_staged_image_revision_at( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + previous_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + write_generation_ledger(root, ledger)?; + } emit(generation_progress_event( ledger, public_phase_name(&record.phase), @@ -561,6 +599,40 @@ fn publish_public_phase( Ok(record) } +fn synchronize_staged_image_revision( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + let (metadata, _) = match read_staged_image_locked(root, &ledger.staged_image_token) { + Ok(staged) => staged, + Err(error) if error == "素材画布 staging 元数据不存在" => return Ok(false), + Err(error) => return Err(error), + }; + if metadata.project_id != ledger.project_id || metadata.draft_id != ledger.draft_id { + return Err("staging 图片与生成账本身份不匹配".to_string()); + } + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if metadata.draft_revision != draft.revision { + rebind_asset_canvas_staged_image_revision_at( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft.revision, + )?; + } + let changed = ledger.current_draft_revision != Some(draft.revision) + || ledger.staged_draft_revision != Some(draft.revision); + ledger.current_draft_revision = Some(draft.revision); + ledger.staged_draft_revision = Some(draft.revision); + if changed { + write_generation_ledger(root, ledger)?; + } + Ok(true) +} + fn normalized_request_fingerprint( input: &GenerateAssetCanvasImageInput, manifest: &GameCreationAppManifest, @@ -738,6 +810,7 @@ fn validate_and_prepare_ledger( commit_idempotency_key: input.commit_idempotency_key.clone(), expected_host_revision: input.expected_host_revision, expected_draft_revision: input.expected_draft_revision, + current_draft_revision: Some(input.expected_draft_revision), prompt: prompt.to_string(), aspect_ratio: input.aspect_ratio.clone(), image_size: input.image_size.clone(), @@ -756,6 +829,7 @@ fn validate_and_prepare_ledger( poll_after_ms: None, remote_result: None, staged_image_token: Uuid::new_v4().to_string(), + staged_draft_revision: None, commit_result: None, error_code: None, created_at: now, @@ -1402,7 +1476,9 @@ fn committed_execution_from_private_result( project_id: committed.project_id.clone(), commit_id: committed.commit_id.clone(), committed_project_revision: committed.committed_project_revision, - draft_revision: committed.draft_revision, + draft_revision: ledger + .current_draft_revision + .unwrap_or(committed.draft_revision), host_revision: committed.host_revision.to_string(), commit_status: committed.commit_status.clone(), manifest, @@ -1456,6 +1532,14 @@ fn sanitized_generation_error(code: &str) -> String { } } +fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String { + if reconciliation && code == "authentication-required" { + return "reconciliation-required: 登录已失效;请重新登录后使用原 operation 恢复" + .to_string(); + } + sanitized_generation_error(code) +} + fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool { matches!( ledger.phase, @@ -1637,7 +1721,7 @@ async fn classify_canvas_submit_error( fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { if error.contains("authentication-required") { - return (false, "authentication-required"); + return (true, "authentication-required"); } if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") { return (false, "insufficient-mud-points"); @@ -1652,6 +1736,16 @@ fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { (false, "generation-failed") } +fn preserve_submit_reconciliation( + phase: &GenerationLedgerPhase, + code: &str, + reconciliation: bool, +) -> bool { + reconciliation + || (code == "authentication-required" + && phase == &GenerationLedgerPhase::ReconciliationRequired) +} + async fn reconcile_generation( root: &Path, mut ledger: AssetCanvasGenerationLedger, @@ -1770,8 +1864,10 @@ async fn reconcile_generation( let status = response.status(); if !status.is_success() { let (code, reconciliation) = classify_canvas_submit_error(response, api_mode).await; + let reconciliation = + preserve_submit_reconciliation(&ledger.phase, code, reconciliation); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(sanitized_generation_error(code)); + return Err(sanitized_classified_generation_error(code, reconciliation)); } let submission = match response.json::().await { Ok(value) => value, @@ -1783,7 +1879,7 @@ async fn reconcile_generation( match classify_canvas_generation_initial_response(status, &submission, api_mode) { Ok(CanvasGenerationInitialResponse::Completed(generated)) => { set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; generated } Ok(CanvasGenerationInitialResponse::Async(submission)) => { @@ -1793,9 +1889,9 @@ async fn reconcile_generation( ledger.operation_id = Some(operation_id); ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission)); set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; match wait_for_canvas_generation_result( &client, api_base_url, @@ -1808,7 +1904,7 @@ async fn reconcile_generation( Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(sanitized_generation_error(code)); + return Err(sanitized_classified_generation_error(code, reconciliation)); } } } @@ -1827,14 +1923,14 @@ async fn reconcile_generation( "pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000), }); set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await { Ok(result) => result, Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; - return Err(sanitized_generation_error(code)); + return Err(sanitized_classified_generation_error(code, reconciliation)); } } } else { @@ -1863,7 +1959,7 @@ async fn reconcile_generation( GenerationLedgerPhase::RemoteCompleted, None, )?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; } if matches!( @@ -1871,13 +1967,7 @@ async fn reconcile_generation( GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired ) && ledger.commit_result.is_none() { - let staged_exists = read_staged_image_locked(root, &ledger.staged_image_token) - .ok() - .is_some_and(|(metadata, _)| { - metadata.project_id == ledger.project_id - && metadata.draft_id == ledger.draft_id - && metadata.draft_revision == ledger.expected_draft_revision - }); + let staged_exists = synchronize_staged_image_revision(root, &mut ledger)?; if !staged_exists { let remote = ledger .remote_result @@ -1921,7 +2011,9 @@ async fn reconcile_generation( project_path: root.to_string_lossy().into_owned(), expected_project_id: ledger.project_id.clone(), draft_id: ledger.draft_id.clone(), - expected_draft_revision: ledger.expected_draft_revision, + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), media_type: download.media_type, bytes: download.bytes, }, @@ -1939,6 +2031,9 @@ async fn reconcile_generation( "download-reconciliation-required", )); } + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + write_generation_ledger(root, &mut ledger)?; } set_private_phase( root, @@ -1946,17 +2041,29 @@ async fn reconcile_generation( GenerationLedgerPhase::MediaDownloaded, None, )?; - publish_public_phase(root, &ledger, emit)?; + publish_public_phase(root, &mut ledger, emit)?; } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + if !synchronize_staged_image_revision(root, &mut ledger)? { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } let commit = commit_asset_canvas_at( root, &CommitAssetCanvasInput { project_path: root.to_string_lossy().into_owned(), expected_project_id: ledger.project_id.clone(), expected_revision: ledger.expected_host_revision, - expected_draft_revision: ledger.expected_draft_revision, + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), draft_id: ledger.draft_id.clone(), commit_id: ledger.commit_id.clone(), idempotency_key: ledger.commit_idempotency_key.clone(), @@ -2042,6 +2149,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("commit-reconciliation-required")); } }; + ledger.current_draft_revision = Some(private_commit.draft_revision); ledger.commit_result = Some(private_commit); set_private_phase( root, @@ -2049,7 +2157,14 @@ async fn reconcile_generation( GenerationLedgerPhase::AssetDurableCommitted, None, )?; - let generation = publish_public_phase(root, &ledger, emit)?; + let generation = publish_public_phase(root, &mut ledger, emit)?; + let final_draft_revision = ledger + .current_draft_revision + .expect("public commit projection must advance the draft revision"); + if let Some(committed) = ledger.commit_result.as_mut() { + committed.draft_revision = final_draft_revision; + } + write_generation_ledger(root, &mut ledger)?; let committed = ledger .commit_result .as_ref() @@ -2065,7 +2180,7 @@ async fn reconcile_generation( project_id: committed.project_id.clone(), commit_id: committed.commit_id.clone(), committed_project_revision: committed.committed_project_revision, - draft_revision: committed.draft_revision, + draft_revision: final_draft_revision, host_revision: committed.host_revision.to_string(), commit_status: committed.commit_status.clone(), manifest, @@ -2357,6 +2472,7 @@ mod tests { commit_idempotency_key: Uuid::new_v4().to_string(), expected_host_revision: 0, expected_draft_revision: draft.revision, + current_draft_revision: Some(draft.revision), prompt: "重启前已经提交的私有正文".to_string(), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), @@ -2381,6 +2497,7 @@ mod tests { poll_after_ms: Some(0), remote_result: None, staged_image_token: Uuid::new_v4().to_string(), + staged_draft_revision: None, commit_result: None, error_code: None, created_at: now, @@ -2504,6 +2621,27 @@ mod tests { assert!(progress .iter() .any(|event| event.phase == "asset-durable-committed")); + assert!(progress + .windows(2) + .all(|events| events[0].draft_revision < events[1].draft_revision)); + assert_eq!( + progress.last().map(|event| event.draft_revision), + Some(first.result.commit.draft_revision) + ); + let private = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read committed private ledger") + .expect("committed private ledger exists"); + assert_eq!( + private.current_draft_revision, + Some(first.result.commit.draft_revision) + ); + assert_eq!( + private.staged_draft_revision, + Some(first.result.commit.draft_revision) + ); + let (staged, _) = read_staged_image_locked(directory.path(), &private.staged_image_token) + .expect("read committed staging image"); + assert_eq!(staged.draft_revision, first.result.commit.draft_revision); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 6); @@ -2716,6 +2854,7 @@ mod tests { commit_idempotency_key: Uuid::new_v4().to_string(), expected_host_revision: 0, expected_draft_revision: 0, + current_draft_revision: Some(0), prompt: "精修提示词".to_string(), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), @@ -2761,6 +2900,7 @@ mod tests { poll_after_ms: None, remote_result: None, staged_image_token: Uuid::new_v4().to_string(), + staged_draft_revision: None, commit_result: None, error_code: None, created_at: now, @@ -2869,6 +3009,53 @@ mod tests { drop(directory); } + #[test] + fn accepted_authentication_failure_stays_recoverable_with_the_original_operation() { + let project_id = "phase-five-auth-reconciliation"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五登录恢复语义测试"); + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + let original_operation = ledger.operation_id.clone(); + let original_request = ledger.request_body_json.clone(); + let (reconciliation, code) = + classify_canvas_generation_error("authentication-required: 登录已失效"); + assert!(reconciliation); + assert_eq!(code, "authentication-required"); + let mut progress = Vec::new(); + mark_generation_error( + directory.path(), + &mut ledger, + reconciliation, + code, + &mut |event| progress.push(event), + ) + .expect("mark authentication reconciliation"); + + assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired); + assert_eq!(ledger.operation_id, original_operation); + assert_eq!(ledger.request_body_json, original_request); + assert!(should_poll_existing_operation(&ledger)); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "reconciliation-required"); + assert_eq!(progress[0].error_code.as_deref(), Some(code)); + assert!(sanitized_classified_generation_error(code, reconciliation) + .starts_with("reconciliation-required:")); + assert!(!preserve_submit_reconciliation( + &GenerationLedgerPhase::Prepared, + code, + false, + )); + assert!(preserve_submit_reconciliation( + &GenerationLedgerPhase::ReconciliationRequired, + code, + false, + )); + } + #[test] fn authenticated_access_token_is_ephemeral_and_never_enters_generation_ledger() { let project_id = "phase-five-authenticated-token"; 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 551e3033c..c11bf1217 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 @@ -93,6 +93,11 @@ export type AssetCanvasSaveAttempt = { commitId: string; }; +export type AssetCanvasExitResult = { + draftId: string; + disposition: 'kept' | 'discarded'; +}; + export type AssetCanvasCommitNotification = { source: 'command' | 'event'; projectPath: string; @@ -323,7 +328,7 @@ export function AssetCanvasSurface({ expectedHostRevision: string; initialAssetName?: string; initialAssetKind?: string; - onCancel?: () => void; + onCancel?: (result: AssetCanvasExitResult) => void; onCommitted?: (input: AssetCanvasCommitNotification) => void; onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void; renderImage?: RenderAssetCanvasImage; @@ -369,6 +374,8 @@ export function AssetCanvasSurface({ >('1K'); const [generationReferenceResourceIds, setGenerationReferenceResourceIds] = useState([]); + const [exitDialogOpen, setExitDialogOpen] = useState(false); + const [exitActionPending, setExitActionPending] = useState(false); const [documentVersion, setDocumentVersion] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const viewportElementRef = useRef(null); @@ -418,6 +425,26 @@ export function AssetCanvasSurface({ setLifecycle({ kind: 'canvas.editing', dirty: true }); }, []); + const applyGenerationProgressRevision = useCallback( + (progress: ImageCanvasGenerationProgress) => { + const revision = progress.draftRevision; + const currentDraft = draftRef.current; + if ( + typeof revision !== 'number' || + !Number.isSafeInteger(revision) || + revision < 0 || + !currentDraft || + revision <= currentDraft.revision + ) { + return; + } + const nextDraft = { ...currentDraft, revision }; + draftRef.current = nextDraft; + setDraft(nextDraft); + }, + [], + ); + const canvasHistoryRefs = useMemo( () => ({ layersRef, @@ -504,6 +531,7 @@ export function AssetCanvasSurface({ }), ); if (epoch !== epochRef.current) return; + draftRef.current = nextDraft; setDraft(nextDraft); setLayers(runtimeLayers); setViewport(nextDraft.canvas.viewport); @@ -609,6 +637,7 @@ export function AssetCanvasSurface({ epoch === epochRef.current && recoveryFocusEpoch === generationFocusEpochRef.current ) { + applyGenerationProgressRevision(progress); setNotice(`正在恢复图片生成:${progress.phase}`); } }, @@ -650,6 +679,7 @@ export function AssetCanvasSurface({ previewUrls.clear(); }; }, [ + applyGenerationProgressRevision, expectedHostRevision, host, hydrateDraft, @@ -1057,11 +1087,12 @@ export function AssetCanvasSurface({ stableScope, ]); - const cancelCanvas = useCallback(() => { + const discardCanvas = useCallback(() => { const currentDraft = draftRef.current; if (!currentDraft || lifecycleRef.current.kind === 'canvas.saving') { return; } + setExitActionPending(true); const epoch = epochRef.current; void host.project .discardDraft({ @@ -1071,7 +1102,11 @@ export function AssetCanvasSurface({ .then((result) => { if (epoch !== epochRef.current) return; if (result.status === 'ok') { - onCancel?.(); + setExitDialogOpen(false); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'discarded', + }); return; } setLifecycle({ @@ -1092,9 +1127,54 @@ export function AssetCanvasSurface({ message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, }); + }) + .finally(() => { + if (epoch === epochRef.current) setExitActionPending(false); }); }, [host.project, onCancel, stableScope]); + const keepDraftAndExit = useCallback(() => { + if (exitActionPending) return; + const currentDraft = draftRef.current; + if (!currentDraft) return; + setExitActionPending(true); + const epoch = epochRef.current; + void persistDraft() + .then((persisted) => { + if (epoch !== epochRef.current || !persisted) return; + setExitDialogOpen(false); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }) + .finally(() => { + if (epoch === epochRef.current) setExitActionPending(false); + }); + }, [exitActionPending, onCancel, persistDraft, stableScope.draftId]); + + const requestCanvasExit = useCallback(() => { + const currentDraft = draftRef.current; + if ( + !currentDraft || + lifecycleRef.current.kind === 'canvas.saving' || + lifecycleRef.current.kind === 'canvas.generating' + ) { + return; + } + if ( + lifecycleRef.current.kind === 'canvas.editing' && + lifecycleRef.current.dirty + ) { + setExitDialogOpen(true); + return; + } + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }, [onCancel, stableScope.draftId]); + const openGenerationDialog = useCallback(() => { const currentDraft = draftRef.current; if ( @@ -1191,6 +1271,7 @@ export function AssetCanvasSurface({ progress.intentId === identity.intentId && progress.generationId === identity.generationId ) { + applyGenerationProgressRevision(progress); setLifecycle({ kind: 'canvas.generating', phase: progress.phase }); setNotice(progress.errorCode ?? ''); } @@ -1287,6 +1368,7 @@ export function AssetCanvasSurface({ } }); }, [ + applyGenerationProgressRevision, assetKind, assetName, generationAspectRatio, @@ -1327,8 +1409,11 @@ export function AssetCanvasSurface({ pendingGenerationRef.current = null; setGenerationDialog(null); setNotice('生成仍会在后台使用原 operation 安全对账'); - onCancel?.(); - }, [onCancel]); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + }, [onCancel, stableScope.draftId]); const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), @@ -1379,7 +1464,7 @@ export function AssetCanvasSurface({ onClick={ lifecycle.kind === 'canvas.generating' ? stopWaitingForGeneration - : cancelCanvas + : requestCanvasExit } disabled={lifecycle.kind === 'canvas.saving'} > @@ -1678,6 +1763,54 @@ export function AssetCanvasSurface({ ) : null} + {exitDialogOpen ? ( +
+
+
+ 返回资源总览 +
+
+

当前画布有尚未完成的编辑,请选择是否保留草稿。

+
+ + + +
+
+
+
+ ) : null} + {lifecycle.kind === 'canvas.generating' ? (