完善全类型资源非破坏性编辑
Project CI / Repository checks (pull_request) Failing after 1m9s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Successful in 4m5s
Project CI / Native shell tests (pull_request) Failing after 6m38s

禁用新增资源并为所有现役资源提供按类型编辑入口。

图片、视频、音频、文本、任务产物和项目版本派生新资源且保留原资源。

完善 External operation 私有账本、幂等恢复、旧指纹迁移与安全校验。

修复画布生成期间交互冻结、滚轮平移缩放与 Shift 选择行为。

同步资源编辑合同、决策记录与排障经验。
This commit is contained in:
2026-08-10 21:19:57 +08:00
parent f37c84234c
commit cf81a71dce
20 changed files with 1835 additions and 122 deletions
@@ -29,7 +29,7 @@ pub(in crate::agent) use canvas_generation::{
validate_platform_art_png_bytes_with_limits,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
pub(crate) use external_generation_state::platform_art_generation_external_configuration_fingerprint;
pub(crate) use external_generation_state::platform_art_generation_external_service_fingerprint;
pub(in crate::agent) use external_generation_state::{
game_creator_agent_runtime_external_generation_exists,
platform_art_generation_runtime_context_from_pending,
@@ -39,6 +39,7 @@ pub(in crate::agent) use external_generation_state::{
};
#[cfg(test)]
pub(crate) use external_generation_state::{
platform_art_generation_external_configuration_fingerprint,
setup_platform_art_generation_runtime_accepted_for_recovery_test,
write_platform_art_generation_runtime_accepted_for_test,
write_platform_art_generation_runtime_prepared_for_test,
@@ -1,13 +1,16 @@
#[cfg(test)]
use super::external_generation_state::platform_art_generation_external_configuration_fingerprint;
use super::external_generation_state::{
mark_platform_art_generation_runtime_accepted,
mark_platform_art_generation_runtime_legacy_completed,
platform_art_generation_external_configuration_fingerprint,
migrate_platform_art_generation_external_configuration,
platform_art_generation_external_service_fingerprint,
platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result,
platform_art_generation_runtime_request_body_json,
platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status,
platform_art_generation_runtime_submission_payload,
prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state,
validate_platform_art_generation_external_configuration, PlatformArtGenerationRuntimeState,
PlatformArtGenerationRuntimeState,
};
use super::*;
@@ -1155,12 +1158,19 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
};
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
let api_key = resolve_canvas_sync_api_key(None)?;
if let Some(state) = persisted_runtime_state.as_ref() {
validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key)
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?;
}
let persisted_runtime_state = persisted_runtime_state
.map(|state| {
migrate_platform_art_generation_external_configuration(
root,
state,
&api_base_url,
&api_key,
)
.map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))
})
.transpose()?;
let external_configuration_fingerprint =
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
platform_art_generation_external_service_fingerprint(&api_base_url);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
@@ -6512,7 +6522,7 @@ mod canvas_generation_tests {
}
#[tokio::test]
async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() {
async fn accepted_runtime_generation_rejects_external_service_drift_before_get() {
let temporary = tempfile::tempdir().expect("create configuration drift project");
let root = temporary.path();
init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移")
@@ -6552,10 +6562,7 @@ mod canvas_generation_tests {
action_fingerprint: "configuration-drift-fingerprint".to_string(),
};
let stale_configuration_fingerprint =
platform_art_generation_external_configuration_fingerprint(
"https://old-editor.example.test",
"old-editor-key",
);
platform_art_generation_external_service_fingerprint("https://old-editor.example.test");
let (state, _) = prepare_platform_art_generation_runtime_state(
root,
&runtime_context,
@@ -6587,7 +6594,7 @@ mod canvas_generation_tests {
Err(error) => error,
Ok(_) => panic!("configuration drift must block GET-only recovery"),
};
assert!(error.contains("baseUrl/API Key"), "{error}");
assert!(error.contains("服务地址身份"), "{error}");
assert!(matches!(
listener.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
@@ -122,7 +122,12 @@ fn request_body_json_and_sha256(
Ok((request_body_json, request_body_sha256))
}
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
}
fn platform_art_generation_legacy_external_configuration_fingerprint(
api_base_url: &str,
api_key: &str,
) -> String {
@@ -134,18 +139,48 @@ pub(crate) fn platform_art_generation_external_configuration_fingerprint(
)
}
// Keep the historical helper for persisted-agent call sites while changing its
// identity semantics: credentials authorize an operation but do not own it.
#[cfg(test)]
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
api_base_url: &str,
_api_key: &str,
) -> String {
platform_art_generation_external_service_fingerprint(api_base_url)
}
pub(super) fn validate_platform_art_generation_external_configuration(
state: &PlatformArtGenerationRuntimeState,
api_base_url: &str,
api_key: &str,
) -> Result<(), String> {
let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key);
if state.external_configuration_fingerprint != current {
return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string());
let current = platform_art_generation_external_service_fingerprint(api_base_url);
let legacy =
platform_art_generation_legacy_external_configuration_fingerprint(api_base_url, api_key);
if state.external_configuration_fingerprint != current
&& state.external_configuration_fingerprint != legacy
{
return Err("External Editor 生成账本与当前服务地址身份不一致".to_string());
}
Ok(())
}
pub(super) fn migrate_platform_art_generation_external_configuration(
root: &Path,
mut state: PlatformArtGenerationRuntimeState,
api_base_url: &str,
api_key: &str,
) -> Result<PlatformArtGenerationRuntimeState, String> {
validate_platform_art_generation_external_configuration(&state, api_base_url, api_key)?;
let current = platform_art_generation_external_service_fingerprint(api_base_url);
if state.external_configuration_fingerprint != current {
state.external_configuration_fingerprint = current;
state.updated_at = unix_timestamp();
write_platform_art_generation_runtime_state(root, &state)?;
}
Ok(state)
}
fn validate_platform_art_generation_runtime_identity(
root: &Path,
state: &PlatformArtGenerationRuntimeState,
@@ -667,9 +702,8 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
let context = platform_art_generation_runtime_context_from_pending(pending);
let api_base_url =
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
let external_configuration_fingerprint =
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
platform_art_generation_external_service_fingerprint(&api_base_url);
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
@@ -700,9 +734,8 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test(
let context = platform_art_generation_runtime_context_from_pending(pending);
let api_base_url =
resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string());
let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string());
let external_configuration_fingerprint =
platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key);
platform_art_generation_external_service_fingerprint(&api_base_url);
let (_, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
@@ -786,6 +819,85 @@ pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test(
mod external_generation_state_tests {
use super::*;
#[test]
fn external_service_fingerprint_ignores_key_rotation_and_normalizes_trailing_slash() {
let original = platform_art_generation_external_configuration_fingerprint(
"https://editor.example.test/",
"original-key",
);
let rotated = platform_art_generation_external_configuration_fingerprint(
"https://editor.example.test",
"rotated-key",
);
let different_service = platform_art_generation_external_service_fingerprint(
"https://other-editor.example.test",
);
assert_eq!(original, rotated);
assert_ne!(original, different_service);
}
#[test]
fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() {
let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-");
let root = temporary.path();
init_local_game_project_at(root, "legacy-fingerprint", "旧配置指纹迁移")
.expect("init project");
let pending = pending_canvas_generation(root);
let context = platform_art_generation_runtime_context_from_pending(&pending);
let api_base_url = "https://editor.example.test/";
let legacy_fingerprint = platform_art_generation_legacy_external_configuration_fingerprint(
api_base_url,
"original-key",
);
let (legacy_state, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
"/api/external/v1/editor/images/generations",
"legacy-fingerprint-canvas",
"恢复旧请求",
&serde_json::json!({
"prompt": "恢复旧请求",
"kind": "spec",
"projectId": "canvas-project",
"assetFolderId": "asset-folder",
"referenceImageSrcs": []
}),
&legacy_fingerprint,
)
.expect("prepare legacy generation ledger");
assert!(created);
let migrated = migrate_platform_art_generation_external_configuration(
root,
legacy_state,
api_base_url,
"original-key",
)
.expect("migrate legacy fingerprint");
assert_eq!(
migrated.external_configuration_fingerprint,
platform_art_generation_external_service_fingerprint(api_base_url)
);
validate_platform_art_generation_external_configuration(
&migrated,
"https://editor.example.test",
"rotated-key",
)
.expect("rotated key must recover the migrated operation");
assert!(validate_platform_art_generation_external_configuration(
&migrated,
"https://other-editor.example.test",
"rotated-key",
)
.is_err());
let persisted = read_platform_art_generation_runtime_state(root, &context)
.expect("read migrated ledger")
.expect("migrated ledger exists");
assert_eq!(persisted, migrated);
}
fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction {
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
@@ -915,12 +1027,12 @@ mod external_generation_state_tests {
"test-api-key",
)
.is_err());
assert!(validate_platform_art_generation_external_configuration(
validate_platform_art_generation_external_configuration(
&prepared,
"https://editor.example.test",
"different-api-key",
)
.is_err());
.expect("rotated API Key must keep ownership of the accepted service operation");
assert_eq!(
platform_art_generation_runtime_recovery_at(root, &pending)
.expect("read prepared recovery"),
@@ -1222,6 +1222,24 @@ pub(crate) async fn derive_local_project_resource(
derive_local_project_resource_at(input).await
}
#[tauri::command]
pub(crate) fn list_pending_local_project_resource_edits(
input: ListPendingLocalProjectResourceEditsInput,
) -> Result<Vec<PendingLocalProjectResourceEdit>, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "file.list")?;
list_pending_local_project_resource_edits_at(input)
}
#[tauri::command]
pub(crate) async fn resume_local_project_resource_edit(
input: ResumeLocalProjectResourceEditInput,
) -> Result<DeriveLocalProjectResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
resume_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) fn normalize_local_project_raster_resource(
input: NormalizeLocalProjectRasterResourceInput,
@@ -2207,6 +2207,8 @@ fn main() {
upload_local_asset,
register_local_asset,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
import_canvas_export,
@@ -1273,7 +1273,9 @@ pub(crate) fn update_asset_canvas_draft_at(
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.status = input.status.clone();
draft.canvas = input.canvas.clone();
draft.generations = input.generations.clone();
// Generation records are advanced by the Rust generation ledger. A regular
// canvas autosave may carry an older frontend snapshot and must never erase
// accepted/running/reconciliation facts written by the backend.
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
Ok(UpdateAssetCanvasDraftResult {
@@ -304,7 +304,8 @@ fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), St
}
fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String {
platform_art_generation_external_configuration_fingerprint(api_base_url, mode.bearer_token())
let _ = mode;
platform_art_generation_external_service_fingerprint(api_base_url)
}
fn authorize_canvas_request(
@@ -842,6 +843,7 @@ fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Optio
!value.is_empty()
&& !value.starts_with("local-asset:")
&& !value.starts_with("draft-media:")
&& !value.starts_with("task:")
})
.map(str::to_string)
}
@@ -938,3 +938,44 @@ fn rejects_bad_signature_oversize_identity_replacement_and_linked_sidecars() {
);
}
}
#[test]
fn ordinary_draft_update_preserves_backend_authoritative_generation_records() {
let fixture = initialize_fixture();
let mut authoritative = fixture.draft.clone();
authoritative.generations.push(AssetCanvasGenerationRecord {
generation_id: Uuid::new_v4().to_string(),
intent_id: Uuid::new_v4().to_string(),
phase: AssetCanvasGenerationStatus::GenerationRunning,
reference_resource_ids: Vec::new(),
output_asset_id: None,
error_code: None,
created_at: 1,
updated_at: 2,
idempotency_key: None,
status: None,
prompt: None,
operation_id: None,
output_media_ids: Vec::new(),
});
write_asset_canvas_draft_locked(fixture.root(), &authoritative)
.expect("write authoritative generation record");
let mut canvas = authoritative.canvas.clone();
canvas.viewport.x = 42.0;
let updated = update_asset_canvas_draft_at(
fixture.root(),
&UpdateAssetCanvasDraftInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: authoritative.draft_id.clone(),
expected_draft_revision: authoritative.revision,
status: AssetCanvasDraftStatus::Editing,
canvas,
generations: Vec::new(),
},
)
.expect("save stale frontend draft");
assert_eq!(updated.draft.generations, authoritative.generations);
assert_eq!(updated.draft.canvas.viewport.x, 42.0);
}
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ import {
moveViewportFromPan,
removeCanvasLayers,
resizeCanvasLayerBounds,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
transformCanvasLayers,
} from '@genarrative/image-canvas-core';
@@ -387,6 +388,7 @@ export function AssetCanvasSurface({
const draftRef = useRef(draft);
const lifecycleRef = useRef(lifecycle);
const documentVersionRef = useRef(documentVersion);
const persistedDocumentVersionRef = useRef(0);
const epochRef = useRef(0);
const dragRef = useRef<DragState | null>(null);
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
@@ -402,6 +404,7 @@ export function AssetCanvasSurface({
const pendingGenerationRef = useRef<PendingGenerationIdentity | null>(null);
const generationStartingRef = useRef(false);
const generationFocusEpochRef = useRef(0);
const generationStopButtonRef = useRef<HTMLButtonElement | null>(null);
const onWalletBalanceMayHaveChanged = useWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
@@ -421,7 +424,12 @@ export function AssetCanvasSurface({
documentVersionRef.current = documentVersion;
const markDirty = useCallback(() => {
setDocumentVersion((value) => value + 1);
if (lifecycleRef.current.kind !== 'canvas.editing') return;
setDocumentVersion((value) => {
const next = value + 1;
documentVersionRef.current = next;
return next;
});
setLifecycle({ kind: 'canvas.editing', dirty: true });
}, []);
@@ -441,8 +449,21 @@ export function AssetCanvasSurface({
const nextDraft = { ...currentDraft, revision };
draftRef.current = nextDraft;
setDraft(nextDraft);
const epoch = epochRef.current;
void host.project.loadDraft(stableScope).then((loaded) => {
if (
epoch !== epochRef.current ||
loaded.status !== 'ok' ||
!loaded.value ||
loaded.value.revision < revision
) {
return;
}
draftRef.current = loaded.value;
setDraft(loaded.value);
});
},
[],
[host.project, stableScope],
);
const canvasHistoryRefs = useMemo(
@@ -538,6 +559,9 @@ export function AssetCanvasSurface({
setBackgroundColor(nextDraft.canvas.backgroundColor);
setSelectedLayerIds(nextDraft.canvas.selectedLayerIds);
resetCanvasHistory();
documentVersionRef.current = 0;
persistedDocumentVersionRef.current = 0;
setDocumentVersion(0);
setLifecycle({ kind: 'canvas.editing', dirty: false });
},
[host, resetCanvasHistory, stableScope],
@@ -552,6 +576,7 @@ export function AssetCanvasSurface({
pendingCommitRef.current = null;
pendingGenerationRef.current = null;
generationStartingRef.current = false;
dragRef.current = null;
generationFocusEpochRef.current += 1;
hostRevisionRef.current = expectedHostRevision;
deliveredEventsRef.current.clear();
@@ -710,7 +735,7 @@ export function AssetCanvasSurface({
const persistDraft =
useCallback(async (): Promise<ImageCanvasDraft | null> => {
const epoch = epochRef.current;
const requestedVersion = documentVersion;
const requestedVersion = documentVersionRef.current;
const task = saveQueueRef.current.then(async () => {
const currentDraft = draftRef.current;
if (!currentDraft || epoch !== epochRef.current) return null;
@@ -748,14 +773,21 @@ export function AssetCanvasSurface({
}
draftRef.current = result.value;
setDraft(result.value);
if (requestedVersion === documentVersionRef.current) {
persistedDocumentVersionRef.current = Math.max(
persistedDocumentVersionRef.current,
requestedVersion,
);
if (
requestedVersion === documentVersionRef.current &&
lifecycleRef.current.kind === 'canvas.editing'
) {
setLifecycle({ kind: 'canvas.editing', dirty: false });
}
return result.value;
});
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [documentVersion, host.project, stableScope]);
}, [host.project, stableScope]);
useEffect(() => {
if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) {
@@ -767,6 +799,10 @@ export function AssetCanvasSurface({
useEffect(() => {
const onMove = (event: PointerEvent) => {
if (lifecycleRef.current.kind !== 'canvas.editing') {
dragRef.current = null;
return;
}
const drag = dragRef.current;
if (!drag) return;
if (drag.kind === 'pan') {
@@ -826,6 +862,10 @@ export function AssetCanvasSurface({
}
};
const onUp = () => {
if (lifecycleRef.current.kind !== 'canvas.editing') {
dragRef.current = null;
return;
}
if (!dragRef.current) return;
dragRef.current = null;
markDirty();
@@ -845,7 +885,12 @@ export function AssetCanvasSurface({
const files = Array.from(event.target.files ?? []);
event.target.value = '';
const currentDraft = draftRef.current;
if (!currentDraft || !files.length) return;
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
!currentDraft ||
!files.length
)
return;
const images = await Promise.all(
files.map(async (file) => {
const mediaType = mediaTypeForFile(file);
@@ -931,7 +976,11 @@ export function AssetCanvasSurface({
);
const deleteSelected = useCallback(() => {
if (!selectionRef.current.length) return;
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
!selectionRef.current.length
)
return;
captureHistory({
type: 'delete-image',
count: selectionRef.current.length,
@@ -971,8 +1020,7 @@ export function AssetCanvasSurface({
const epoch = saveEpoch;
if (!draftRef.current) return;
if (
lifecycleRef.current.kind === 'canvas.editing' &&
lifecycleRef.current.dirty
documentVersionRef.current !== persistedDocumentVersionRef.current
) {
setLifecycle({ kind: 'canvas.saving', stage: 'draft' });
const persisted = await persistDraft();
@@ -1053,6 +1101,7 @@ export function AssetCanvasSurface({
});
}
pendingCommitRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setNotice(
result.value.commitStatus === 'already-committed'
? '素材已提交,本次返回原幂等结果'
@@ -1162,10 +1211,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
lifecycleRef.current.kind === 'canvas.editing' &&
lifecycleRef.current.dirty
) {
if (documentVersionRef.current !== persistedDocumentVersionRef.current) {
setExitDialogOpen(true);
return;
}
@@ -1237,9 +1283,9 @@ export function AssetCanvasSurface({
const frozenAssetKind = assetKind;
const frozenAssetName = assetName;
const needsDraftPersist =
lifecycleRef.current.kind === 'canvas.editing' &&
lifecycleRef.current.dirty;
documentVersionRef.current !== persistedDocumentVersionRef.current;
setGenerationDialog(null);
dragRef.current = null;
setLifecycle({
kind: 'canvas.generating',
phase: 'confirmation-required',
@@ -1347,6 +1393,7 @@ export function AssetCanvasSurface({
});
}
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
setNotice('AI 图片已正式提交并进入资源总览');
})().catch((error: unknown) => {
@@ -1419,6 +1466,14 @@ export function AssetCanvasSurface({
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
);
const generationInteractionLocked = lifecycle.kind === 'canvas.generating';
useEffect(() => {
if (generationInteractionLocked) {
dragRef.current = null;
generationStopButtonRef.current?.focus();
}
}, [generationInteractionLocked]);
if (
!draft ||
@@ -1829,6 +1884,7 @@ export function AssetCanvasSurface({
/>
<p>生成会由平台按当前账号扣除泥点,完成后自动保存到本地项目。</p>
<CanvasChromeButton
ref={generationStopButtonRef}
label="停止等待并返回"
icon={<ArrowLeft size={15} aria-hidden="true" />}
onClick={stopWaitingForGeneration}
@@ -1889,8 +1945,11 @@ export function AssetCanvasSurface({
ref={viewportElementRef}
className="asset-canvas-surface__viewport"
backgroundColor={backgroundColor}
isInteractionPaused={generationInteractionLocked}
inert={generationInteractionLocked || undefined}
isPanning={dragRef.current?.kind === 'pan'}
onPointerDown={(event) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
if (event.target !== event.currentTarget) return;
captureHistory({ type: 'change-viewport' });
setSelectedLayerIds([]);
@@ -1903,17 +1962,23 @@ export function AssetCanvasSurface({
};
}}
onWheel={(event) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
setViewport((current) =>
scaleViewportFromScreenPoint({
viewport: current,
nextScale: current.scale * (event.deltaY < 0 ? 1.12 : 0.88),
screenPoint: {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
}),
setViewport(
(current) =>
resolveViewportFromWheel({
viewport: current,
deltaX: event.deltaX,
deltaY: event.deltaY,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
screenPoint: {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
}).viewport,
);
markDirty();
}}
@@ -1936,6 +2001,11 @@ export function AssetCanvasSurface({
aria-label={`选择图层 ${layer.title}`}
aria-pressed={selected}
onClick={(event) => {
if (
event.detail !== 0 ||
lifecycleRef.current.kind !== 'canvas.editing'
)
return;
const targetIds = event.shiftKey
? selected
? selectedLayerIds.filter((id) => id !== layer.id)
@@ -1952,6 +2022,7 @@ export function AssetCanvasSurface({
}
}}
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
event.stopPropagation();
const targetIds = event.shiftKey
? selected
@@ -2005,6 +2076,7 @@ export function AssetCanvasSurface({
aria-label={`缩放图层 ${layer.title}`}
className="asset-canvas-surface__resize-handle"
onPointerDown={(event) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
event.stopPropagation();
captureHistory({
type: 'resize-image',
@@ -2022,6 +2094,7 @@ export function AssetCanvasSurface({
};
}}
onKeyDown={(event) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
if (!event.key.startsWith('Arrow')) return;
event.preventDefault();
const direction =
@@ -2058,10 +2131,15 @@ export function AssetCanvasSurface({
</CanvasWorld>
</SharedCanvasViewport>
<aside className="asset-canvas-surface__viewport-tools">
<aside
className="asset-canvas-surface__viewport-tools"
inert={generationInteractionLocked || undefined}
aria-hidden={generationInteractionLocked || undefined}
>
<ZoomControls
viewport={viewport}
onFit={() => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
const next = fitViewportToLayers({ layers, canvasSize });
if (next) {
captureHistory({ type: 'change-viewport' });
@@ -2070,6 +2148,7 @@ export function AssetCanvasSurface({
}
}}
onScaleFromCenter={(scale) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
captureHistory({ type: 'change-viewport' });
setViewport((current) =>
scaleViewportFromScreenPoint({
@@ -2114,6 +2193,7 @@ export function AssetCanvasSurface({
<Minimap
model={minimapModel}
onPointerDown={(event) => {
if (lifecycleRef.current.kind !== 'canvas.editing') return;
const rect = event.currentTarget.getBoundingClientRect();
captureHistory({ type: 'change-viewport' });
setViewport(
@@ -125,6 +125,15 @@ type DeriveLocalProjectResourceResult = {
manifest: GameCreationAppManifest;
};
type PendingLocalProjectResourceEdit = {
operationId: string;
editKind: string;
sourceResourceId: string;
assetName: string;
phase: string;
createdAt: number;
};
type NormalizeLocalProjectRasterResourceResult = {
committedProjectRevision: number;
asset: GameCreationAppAssetManifestEntry;
@@ -498,6 +507,12 @@ export default function ProjectDevelopmentView({
const [resourceEditorRoute, setResourceEditorRoute] =
useState<ResourceEditorRoute | null>(null);
const [assetCanvasNotice, setAssetCanvasNotice] = useState('');
const [pendingResourceEdits, setPendingResourceEdits] = useState<
PendingLocalProjectResourceEdit[]
>([]);
const [resumingResourceEditId, setResumingResourceEditId] = useState<
string | null
>(null);
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
@@ -974,6 +989,8 @@ export default function ProjectDevelopmentView({
setResourceEditorRoute(null);
resourceEditorRevisionRef.current.clear();
setHiddenCommittedResourceId(null);
setPendingResourceEdits([]);
setResumingResourceEditId(null);
suppressResourceFocusRestoreRef.current = true;
previousFocusedResourceIdRef.current = null;
resourceFocusTriggerIdRef.current = null;
@@ -983,6 +1000,33 @@ export default function ProjectDevelopmentView({
restoreResourceListScrollRef.current = false;
}, [advanceFocusGeneration, projectPath]);
useEffect(() => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setPendingResourceEdits([]);
return undefined;
}
let active = true;
void invoke<PendingLocalProjectResourceEdit[]>(
'list_pending_local_project_resource_edits',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
},
},
)
.then((edits) => {
if (active) setPendingResourceEdits(edits);
})
.catch(() => {
if (active) setPendingResourceEdits([]);
});
return () => {
active = false;
};
}, [manifest.projectId, projectPath]);
useEffect(() => {
if (!focusedResourceId || resourceEditorRoute) {
return undefined;
@@ -1454,6 +1498,71 @@ export default function ProjectDevelopmentView({
setAssetCanvasNotice('');
}, [advanceFocusGeneration, resourceEditorRoute]);
const resumePendingResourceEdit = useCallback(
async (pending: PendingLocalProjectResourceEdit) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || resumingResourceEditId) return;
const flowId = crypto.randomUUID();
activeFocusFlowIdRef.current = flowId;
setResumingResourceEditId(pending.operationId);
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
try {
const result = await invoke<DeriveLocalProjectResourceResult>(
'resume_local_project_resource_edit',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
operationId: pending.operationId,
},
},
);
if (result.manifest.projectId !== manifest.projectId) {
throw new Error('恢复的资源编辑结果与当前项目不一致');
}
const resourceId = result.asset
? `asset:${result.asset.id}`
: result.version
? `version:${result.version.versionId}`
: null;
if (!resourceId) {
throw new Error('资源编辑恢复完成但没有返回派生资源');
}
onManifestChange?.(projectPath, result.manifest, {
projectId: result.manifest.projectId,
revision: result.committedProjectRevision,
source: 'asset-command',
commitId: pending.operationId,
});
pendingResourceFocusRef.current = {
flowId,
saveAttemptId: pending.operationId,
sessionId: pending.operationId,
draftId: pending.operationId,
commitId: pending.operationId,
projectPath,
projectId: result.manifest.projectId,
focusGeneration: focusGenerationRef.current,
resourceId,
completed: false,
};
setPendingResourceEdits((current) =>
current.filter((edit) => edit.operationId !== pending.operationId),
);
setFocusedResourceId(null);
setMode('resources');
setAssetCanvasNotice('原资源编辑已恢复,正在同步派生资源与布局…');
} catch (error) {
setAssetCanvasNotice(
error instanceof Error ? error.message : String(error),
);
} finally {
setResumingResourceEditId(null);
}
},
[manifest.projectId, onManifestChange, projectPath, resumingResourceEditId],
);
const submitResourceEdit = useCallback(
async ({ prompt, assetName }: ResourceEditSubmitInput) => {
const route = resourceEditorRoute;
@@ -1854,6 +1963,21 @@ export default function ProjectDevelopmentView({
!assetCanvasRoute &&
!resourceEditorRoute ? (
<>
{pendingResourceEdits.length > 0 ? (
<button
type="button"
disabled={resumingResourceEditId !== null}
onClick={() => {
const pending = pendingResourceEdits[0];
if (pending) void resumePendingResourceEdit(pending);
}}
>
<Sparkles size={15} aria-hidden="true" />
{resumingResourceEditId
? '正在继续编辑…'
: `继续未完成编辑 (${pendingResourceEdits.length})`}
</button>
) : null}
<button
type="button"
disabled
@@ -159,6 +159,7 @@ function memoryHost(input?: {
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
generationFailure?: { code: string; message: string };
updateFailure?: { code: string; message: string };
recoveryEvent?: LocalAssetCommittedEvent;
}) {
let draft = input?.initialDraft ?? null;
@@ -217,6 +218,13 @@ function memoryHost(input?: {
return { status: 'ok', value: draft };
},
async updateDraft(update) {
if (input?.updateFailure) {
return {
status: 'failed' as const,
code: input.updateFailure.code,
message: input.updateFailure.message,
};
}
if (!draft || update.expectedDraftRevision !== draft.revision) {
return {
status: 'conflict',
@@ -668,6 +676,29 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(memory.discardDraft).not.toHaveBeenCalled();
});
it('草稿自动保存失败后返回仍要求明确保留或放弃', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
updateFailure: {
code: 'fixture-save-failed',
message: '测试草稿保存失败',
},
});
const { onCancel } = renderSurface(memory.host);
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 第一层' }),
);
expect((await screen.findByRole('alert')).textContent).toContain(
'测试草稿保存失败',
);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(
screen.getByRole('dialog', { name: '返回资源总览' }),
).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('只有明确放弃草稿时才写入 cancelled', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
@@ -718,6 +749,76 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
});
});
it('Shift 指针选择在 pointerdown 到 click 的完整序列中只切换一次', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.pointerDown(first, { pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.pointerUp(window, { pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.click(first, { detail: 1 });
expect(first.getAttribute('aria-pressed')).toBe('true');
fireEvent.pointerDown(second, {
pointerId: 2,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.pointerUp(window, {
pointerId: 2,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.click(second, { detail: 1, shiftKey: true });
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('true');
});
it('Tauri 画布滚轮支持二维平移、Shift 横移和 Ctrl 缩放', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
await screen.findByRole('button', { name: '选择图层 第一层' });
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)');
fireEvent.wheel(viewport, { deltaX: 4, deltaY: 10, clientX: 100, clientY: 80 });
await waitFor(() =>
expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'),
);
fireEvent.wheel(viewport, {
deltaX: 0,
deltaY: 10,
clientX: 100,
clientY: 80,
shiftKey: true,
});
await waitFor(() =>
expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'),
);
fireEvent.wheel(viewport, {
deltaX: 0,
deltaY: -10,
clientX: 100,
clientY: 80,
ctrlKey: true,
});
await waitFor(() => expect(world.style.transform).not.toContain('scale(0.5)'));
});
it('新建、导入、编辑、撤销重做并完成 durable commit', async () => {
const memory = memoryHost();
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
@@ -998,6 +1099,63 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it('生成期间聚焦停止等待按钮并阻止所有画布输入修改', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationGate: gate,
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(first);
await waitFor(() => expect(first.getAttribute('aria-pressed')).toBe('true'));
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '生成期间完全锁定画布交互' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
const stopWaiting = screen
.getAllByRole('button', { name: '停止等待并返回' })
.at(-1)!;
await waitFor(() => expect(document.activeElement).toBe(stopWaiting));
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
const transformBefore = world.style.transform;
const resize = screen.getByRole('button', { name: '缩放图层 第一层' });
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.wheel(viewport, {
deltaY: -120,
ctrlKey: true,
clientX: 100,
clientY: 80,
});
fireEvent.pointerDown(viewport, { pointerId: 3, clientX: 10, clientY: 10 });
fireEvent.pointerMove(window, { pointerId: 3, clientX: 100, clientY: 100 });
fireEvent.pointerUp(window, { pointerId: 3, clientX: 100, clientY: 100 });
fireEvent.pointerDown(second, { pointerId: 4, shiftKey: true });
fireEvent.pointerUp(window, { pointerId: 4, shiftKey: true });
fireEvent.click(second, { detail: 1, shiftKey: true });
fireEvent.keyDown(resize, { key: 'ArrowRight' });
fireEvent.click(
screen.getByRole('button', { name: '放大画布', hidden: true }),
);
expect(world.style.transform).toBe(transformBefore);
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('false');
await act(async () => gate.resolve());
});
it('泥点不足时在中央保留失败状态和生成参数,并可返回修改', async () => {
const prompt = '保留参数的原创游戏场景';
const memory = memoryHost({
@@ -282,11 +282,14 @@ describe('project resource live canvas integration', () => {
canvasFixture.sequence = 0;
});
function installTauri(options: { failFirstDerive?: boolean } = {}) {
function installTauri(
options: { failFirstDerive?: boolean; pendingResourceEdit?: boolean } = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
@@ -339,6 +342,52 @@ describe('project resource live canvas integration', () => {
content: '# 玩法规则',
};
}
if (command === 'list_pending_local_project_resource_edits') {
return options.pendingResourceEdit
? [
{
operationId: '99999999-9999-4999-8999-999999999999',
editKind: 'text',
sourceResourceId: 'rules-resource',
assetName: '恢复的规则编辑版',
phase: 'media-downloaded',
createdAt: 1,
},
]
: [];
}
if (command === 'resume_local_project_resource_edit') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
resumeCalls.push(input);
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const operationId = String(input.operationId);
const asset = {
id: `edit-${operationId}`,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `assets/edits/${operationId}-recovered-rules.md`,
source: {
kind: 'generated' as const,
resourceId: `local-asset:edit-${operationId}`,
referenceResourceIds: ['rules-resource'],
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
operationId,
editKind: 'text',
sourceResourceId: 'rules-resource',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest: nextManifest,
};
}
if (command === 'derive_local_project_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
@@ -412,7 +461,13 @@ describe('project resource live canvas integration', () => {
core: { invoke },
event: { listen: async () => () => undefined },
};
return { deriveCalls, graphReads, layoutWrites, normalizeCalls };
return {
deriveCalls,
graphReads,
layoutWrites,
normalizeCalls,
resumeCalls,
};
}
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
@@ -563,4 +618,28 @@ describe('project resource live canvas integration', () => {
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
});
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
const { resumeCalls } = installTauri({ pendingResourceEdit: true });
render(<DerivedWorkbench />);
const resume = await screen.findByRole('button', {
name: '继续未完成编辑 (1)',
});
fireEvent.click(resume);
await waitFor(() => expect(resumeCalls).toHaveLength(1));
expect(resumeCalls[0]).toEqual({
projectPath,
expectedProjectId: 'live-canvas-project',
operationId: '99999999-9999-4999-8999-999999999999',
});
expect(resumeCalls[0]).not.toHaveProperty('prompt');
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
expect(
await screen.findByRole('region', {
name: /recovered-rules\.md/,
}),
).not.toBeNull();
});
});