From ba105767aea5f19b9f94c5782e760e8696d2256f Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 23 Aug 2026 16:21:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E6=9D=BF=E7=94=9F=E6=88=90=E4=BA=8B=E5=8A=A1=E4=B8=8E=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E7=BC=96=E8=BE=91=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成失败时持久化失败或对账状态,避免遗留 running 任务 增加候选落盘与 archive-pending 可恢复事务阶段 收紧 Host Port 必选方法并统一 unsupported-capability 合同 钳制快速编辑卡 viewport 定位并补齐回归测试与技术合同 修正认证恢复和候选生成测试断言 --- .../src/project/asset_canvas/generation.rs | 150 +++++++++++++++-- .../asset-canvas/AssetCanvasSurface.tsx | 155 +++++++++++++++--- .../tauriImageCanvasHostAdapter.ts | 1 + .../tests/assetCanvasSurface.test.tsx | 45 +++++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 8 + packages/image-canvas-core/src/ports.ts | 4 +- 6 files changed, 323 insertions(+), 40 deletions(-) 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 247792af2..08e0e9385 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 @@ -184,6 +184,7 @@ enum GenerationLedgerPhase { CandidateReady, AssetDurableCommitted, Failed, + ArchivePending, Archived, ReconciliationRequired, } @@ -201,6 +202,7 @@ impl GenerationLedgerPhase { Self::CandidateReady => "candidate-ready", Self::AssetDurableCommitted => "asset-durable-committed", Self::Failed => "failed", + Self::ArchivePending => "archive-pending", Self::Archived => "archived", Self::ReconciliationRequired => "reconciliation-required", } @@ -855,6 +857,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option Some(AssetCanvasGenerationStatus::Failed), + GenerationLedgerPhase::ArchivePending => None, GenerationLedgerPhase::Archived => None, GenerationLedgerPhase::ReconciliationRequired => { Some(AssetCanvasGenerationStatus::ReconciliationRequired) @@ -979,9 +982,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() } } match (&ledger.phase, ledger.archived_at) { - (GenerationLedgerPhase::Archived, Some(archived_at)) => { + ( + GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived, + Some(archived_at), + ) => { validate_safe_revision(archived_at, "archivedAt")?; } + (GenerationLedgerPhase::ArchivePending, None) => { + return Err("待归档的素材画布生成账本缺少 archivedAt".to_string()); + } (GenerationLedgerPhase::Archived, None) => { return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); } @@ -2948,6 +2957,35 @@ fn preserve_submit_reconciliation( )) } +fn finish_archive_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + if ledger.phase != GenerationLedgerPhase::ArchivePending { + return Err("素材画布生成账本不在待归档阶段".to_string()); + } + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; + if let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id) + { + draft.generations.remove(index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, ledger)?; + Ok(draft) +} + async fn reconcile_generation( root: &Path, mut ledger: AssetCanvasGenerationLedger, @@ -2965,6 +3003,10 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::Archived { return Err("素材画布失败生成已归档".to_string()); } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + finish_archive_pending_at(root, &mut ledger)?; + return Err("素材画布失败生成已归档".to_string()); + } migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( @@ -3039,8 +3081,7 @@ async fn reconcile_generation( (false, "platform-service-configuration") }; if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -3055,8 +3096,7 @@ async fn reconcile_generation( { let code = error.code(); if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -3339,13 +3379,13 @@ async fn reconcile_generation( "candidate-reconciliation-required", )); } + let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; set_private_phase( root, &mut ledger, GenerationLedgerPhase::CandidateReady, None, )?; - let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; emit(generation_progress_event( &ledger, public_phase_name(&generation.phase), @@ -3613,11 +3653,23 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( draft, }); } - if draft.revision != input.expected_draft_revision { - return Err("draft-revision-conflict".to_string()); + if ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, &mut ledger)?; + return Ok(ArchiveAssetCanvasGenerationResult { + generation_id: ledger.generation_id, + phase: GenerationLedgerPhase::Archived.as_str().to_string(), + archived_at: ledger + .archived_at + .ok_or_else(|| "待归档的素材画布生成账本缺少 archivedAt".to_string())?, + draft, + }); } let generation_index = generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } let public_generation = &draft.generations[generation_index]; if public_generation.intent_id != ledger.intent_id { return Err("待归档的素材画布公开生成记录身份无效".to_string()); @@ -3627,10 +3679,11 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( if public_generation.phase != AssetCanvasGenerationStatus::Failed { return Err("只有公开状态明确失败的素材画布生成可以归档".to_string()); } - ledger.phase = GenerationLedgerPhase::Archived; + ledger.phase = GenerationLedgerPhase::ArchivePending; ledger.archived_at = Some(asset_canvas_now()); write_generation_ledger(root, &mut ledger)?; } + GenerationLedgerPhase::ArchivePending => {} GenerationLedgerPhase::Archived => {} GenerationLedgerPhase::ReconciliationRequired => { return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string()); @@ -3649,6 +3702,10 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( validate_safe_revision(draft.revision, "草稿 revision")?; draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; + if ledger.phase == GenerationLedgerPhase::ArchivePending { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, &mut ledger)?; + } Ok(ArchiveAssetCanvasGenerationResult { generation_id: ledger.generation_id, phase: ledger.phase.as_str().to_string(), @@ -3722,12 +3779,19 @@ pub(crate) async fn recover_asset_canvas_generations_at( } let mut recoverable_generation_ids = Vec::new(); for generation_id in generation_ids { - let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; - if ledger.project_id == input.expected_project_id - && ledger.draft_id == input.draft_id - && ledger.phase != GenerationLedgerPhase::AssetDurableCommitted + if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id { + continue; + } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + let _ = finish_archive_pending_at(root, &mut ledger); + continue; + } + if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted && (ledger.phase != GenerationLedgerPhase::Failed || retryable_credential_failure_phase(&ledger).is_some()) { @@ -4989,6 +5053,60 @@ mod tests { .generations .iter() .any(|record| record.generation_id == input.generation_id)); + + let mut pending = archived_ledger.clone(); + pending.phase = GenerationLedgerPhase::ArchivePending; + pending.archived_at = Some(archived.archived_at); + let mut pending_draft = archived.draft.clone(); + pending_draft.generations.push(AssetCanvasGenerationRecord { + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + phase: AssetCanvasGenerationStatus::Failed, + reference_resource_ids: Vec::new(), + output_asset_id: None, + source_layer_id: None, + placeholder: None, + error_code: Some("generation-rejected".to_string()), + created_at: 1, + updated_at: 1, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }); + pending_draft.revision += 1; + write_asset_canvas_draft_locked(directory.path(), &pending_draft) + .expect("write pending archive draft"); + write_generation_ledger(directory.path(), &mut pending) + .expect("write pending archive ledger"); + let pending_recovery = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("recover pending archive"); + assert!(pending_recovery.result.resumed_generation_ids.is_empty()); + assert_eq!( + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered pending archive ledger") + .expect("recovered pending archive ledger exists") + .phase, + GenerationLedgerPhase::Archived + ); + assert!( + !read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered pending archive draft") + .expect("recovered pending archive draft exists") + .generations + .iter() + .any(|record| record.generation_id == input.generation_id) + ); } #[tokio::test] @@ -6686,12 +6804,12 @@ mod tests { resumed.platform_owner_user_id.as_deref(), Some("original-login-owner") ); - assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted); + assert_eq!(resumed.phase, GenerationLedgerPhase::CandidateReady); assert_eq!(resumed.error_code, None); - assert!(resumed.commit_result.is_some()); + assert!(resumed.commit_result.is_none()); let manifest = current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); - assert_eq!(manifest.assets.len(), 1); + assert!(manifest.assets.is_empty()); } #[tokio::test] 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 a3e4a559c..b4b1ddf7d 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 @@ -185,6 +185,36 @@ export function generationAspectRatioForOriginalImage( ); } +export function resolveQuickEditPanelPosition(input: { + anchorX: number; + belowTop: number; + aboveTop: number; + canvasSize: { width: number; height: number }; + panelSize: { width: number; height: number }; + edgePadding?: number; +}) { + const edgePadding = input.edgePadding ?? 12; + const panelWidth = Math.max(0, input.panelSize.width); + const panelHeight = Math.max(0, input.panelSize.height); + const minCenterX = edgePadding + panelWidth / 2; + const maxCenterX = Math.max( + minCenterX, + input.canvasSize.width - edgePadding - panelWidth / 2, + ); + const maxTop = Math.max( + edgePadding, + input.canvasSize.height - edgePadding - panelHeight, + ); + const top = + input.belowTop + panelHeight <= input.canvasSize.height - edgePadding + ? input.belowTop + : input.aboveTop; + return { + left: Math.min(Math.max(input.anchorX, minCenterX), maxCenterX), + top: Math.min(Math.max(top, edgePadding), maxTop), + }; +} + type RuntimeGenerationTask = { generationId: string; sourceLayerId: string | null; @@ -765,7 +795,12 @@ export function AssetCanvasSurface({ const [documentVersion, setDocumentVersion] = useState(0); const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const [quickEditPanelSize, setQuickEditPanelSize] = useState({ + width: 0, + height: 0, + }); const viewportElementRef = useRef(null); + const quickEditPanelRef = useRef(null); const importInputRef = useRef(null); const layersRef = useRef(layers); const viewportRef = useRef(viewport); @@ -1629,7 +1664,6 @@ export function AssetCanvasSurface({ const handleLocalImport = useCallback(async () => { if ( - !host.asset.importLocalImages || lifecycleRef.current.kind !== 'canvas.editing' || backgroundInteractionLockedRef.current ) { @@ -2426,10 +2460,49 @@ export function AssetCanvasSurface({ setQuickEditOpen(false); dragRef.current = null; setNotice('生成任务已提交,结果会作为新候选加入画布'); + const persistGenerationFailure = async ( + code: string, + message: string, + ) => { + const currentDraft = draftRef.current; + if (!currentDraft) return false; + const phase = + code === 'reconciliation-required' + ? ('reconciliation-required' as const) + : ('failed' as const); + const failedDraft = { + ...currentDraft, + status: 'editing' as const, + generations: currentDraft.generations.map((record) => + record.generationId === identity.generationId + ? { + ...record, + phase, + errorCode: code, + updatedAt: Date.now(), + } + : record, + ), + }; + draftRef.current = failedDraft; + setDraft(failedDraft); + const persisted = await persistDraft(); + if (!persisted) { + setNotice(`${message};失败状态保存未完成,请重新打开后对账`); + return false; + } + return true; + }; const task = (async () => { if (needsDraftPersist) { const persisted = await persistDraft(); - if (!persisted) return; + if (!persisted) { + await persistGenerationFailure( + 'draft-save-failed', + '生成任务占位保存失败', + ); + return; + } } const currentDraft = draftRef.current; if ( @@ -2488,6 +2561,12 @@ export function AssetCanvasSurface({ } if (result.status !== 'ok') { const code = result.status === 'failed' ? result.code : result.status; + await persistGenerationFailure( + code, + result.status === 'failed' + ? result.message + : '图片生成发生 revision 冲突', + ); setGenerationTasks((current) => current.map((generationTask) => generationTask.generationId === identity.generationId @@ -2523,11 +2602,14 @@ export function AssetCanvasSurface({ persistedDocumentVersionRef.current = documentVersionRef.current; setLifecycle({ kind: 'canvas.editing', dirty: false }); setNotice('候选图片已加入画布,请选择满意结果设为最终图'); - })().catch((error: unknown) => { + })().catch(async (error: unknown) => { if ( epoch === epochRef.current && focusEpoch === generationFocusEpochRef.current ) { + const message = + error instanceof Error ? error.message : String(error); + await persistGenerationFailure('canvas-generation-failed', message); setGenerationTasks((current) => current.map((generationTask) => generationTask.generationId === identity.generationId @@ -2541,7 +2623,7 @@ export function AssetCanvasSurface({ ), ); setLifecycle({ kind: 'canvas.editing', dirty: false }); - setNotice(error instanceof Error ? error.message : String(error)); + setNotice(message); } }); void task.finally(() => { @@ -2573,7 +2655,6 @@ export function AssetCanvasSurface({ const archiveFailedGeneration = useCallback( async (generationId: string) => { if ( - !host.generation.archiveFailedGeneration || archivingGenerationIds.includes(generationId) || !generationTasks.some( (task) => @@ -2731,18 +2812,55 @@ export function AssetCanvasSurface({ quickEditOpen && quickEditSourceLayerId ? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null) : null; + useLayoutEffect(() => { + const element = quickEditPanelRef.current; + if (!element || !quickEditSourceLayer) return undefined; + const measure = () => + setQuickEditPanelSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + measure(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [quickEditSourceLayer, quickEditSourceLayerId, quickEditOpen]); const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer - ? { - left: + ? (() => { + const edgePadding = 12; + const panelWidth = + quickEditPanelSize.width || + Math.min(672, canvasSize.width - edgePadding * 2); + const panelHeight = + quickEditPanelSize.height || + Math.min(352, canvasSize.height - edgePadding * 2); + const anchorX = viewport.x + (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * - viewport.scale, - top: + viewport.scale; + const belowTop = viewport.y + (quickEditSourceLayer.y + quickEditSourceLayer.height) * viewport.scale + - 12, - } + edgePadding; + const aboveTop = + viewport.y + + quickEditSourceLayer.y * viewport.scale - + panelHeight - + edgePadding; + return resolveQuickEditPanelPosition({ + anchorX, + belowTop, + aboveTop, + canvasSize, + panelSize: { width: panelWidth, height: panelHeight }, + edgePadding, + }); + })() : null; const currentFinalLayer = (draft?.lastCommit @@ -2919,13 +3037,7 @@ export function AssetCanvasSurface({ label="导入图片" title="导入本地图片" icon={