完善全类型资源非破坏性编辑
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();
});
});
@@ -6354,7 +6354,7 @@
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`;提交结果未知时复用原 endpoint、原始正文和原键恢复 POST,轮询超时时保留已有 `operationId` 并只继续 GET,不得换键重提。
- 发布窗口兼容与幂等恢复:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化 endpoint、精确请求体字节、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;`accepted` 账本只恢复 GET`prepared` 只允许在身份、配置指纹与请求 SHA 校验通过后使用账本中的原 endpoint、原始正文和同一键恢复 POST。该 POST 是 External v1 服务端幂等合同下的同一逻辑提交恢复,不放宽通用 ToolHost 未知副作用禁重放规则。恢复得到 `202` 后升级同一账本并继续 GET,旧 `200` 走兼容完成路径;再次 transport 失败仍保留同一账本。显式“继续/恢复”复用原 action、pending、provider batch 和生成账本身份,不创建 successor 请求或新键。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时保留 `accepted + operationId` 并在恢复时继续 GET;旧 `200` 结果损坏、`202` 缺 operationId、状态损坏、透明派生失败或外部完成后的本地提交失败仍进入对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。
- 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}``queued/running` 返回 phase/progress`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。
- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不恢复 POST 或查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可判定为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时 `401 / 403` 等响应不能证明原请求未入队,不得删除账本。其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- 客户端 durable 查询约束:私有生成账本绑定归一化 base URL 的服务身份指纹,服务地址不一致不恢复 POST 或查询旧 operationDeveloper API Key 只负责授权,轮换不改变已有 operation 身份。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可判定为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时 `401 / 403` 等响应不能证明原请求未入队,不得删除账本。其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md``references/capability-routing.md``references/api-operations.md``references/authentication-and-safety.md``references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
@@ -6948,6 +6948,14 @@
- 分类:内部稳定 reason code 固定为 `translation_invalid / translation_upstream_failed / translation_budget_exhausted / elevenlabs_http_failed / invalid_audio / duration_probe_failed / oss_failed / writeback_failed`。MIME、空 body 和大小归 `invalid_audio`MP3 识别、帧读取和时长门禁归 `duration_probe_failed`。普通用户继续只读稳定短文案,不暴露 endpoint、上游正文或凭据。
- 跨入口:画布 Agent `generate-sound-effect` 与站内 / External v1 共用 canonical Prompt、固定模型、nullable `0.5-30` 小数时长和 Loop;省略 duration 为手动 `5s`,显式 null 为自动。SFX 参数解析必须保留该 null,不能被通用 null-default 兼容层改写。最终仍进入相同 `editor_sound_effect_generation` queue payload,不新增 Agent 专属链路。
- 发布边界:T6 工程实施和 mock / loopback 门禁不等于真实 provider 或生产验收。发布前关闭 SFX 入队,使用显式 `--server` / `--server-url` 只读查询 `external_generation_job` 中 pending / running 的 `editor_sound_effect_generation`,清零后按 api-server / Worker → Web 顺序部署并灰度;禁止 `--root-dir`、删除任务伪造 drain 或自动回退 Vidu。本次没有 SpacetimeDB schema、migration 或 bindings 变更。
## 2026-08-10 客户端全类型资源编辑恢复身份与交互门禁
- 资源编辑 operation 以私有账本中的原 endpoint、请求字节、幂等键和 `operationId` 为唯一恢复身份;账本同时冻结源 asset、媒体类型、资源 kind、生产任务和源版本快照。旧账本只从当前 manifest、已完成任务产物、版本数组及受控扩展名兼容恢复,不能信任 UI 路径或前端重新构造的来源。
- Developer API Key 允许轮换,配置身份只绑定 External 服务地址。升级前包含 Key 的 Agent 生成指纹只在当前 Key 可验证原服务时迁移为仅服务地址指纹,迁移后 Key 轮换继续沿用原 operation;无法区分旧 Key 轮换与服务漂移时失败关闭。operation 已建立后遇到 401/403/404、超时或临时网关错误都保留原 operation;恢复命令仅接收项目与 operation 身份,不接收 Key 或新请求正文。
- pending 扫描上限覆盖目录全部实际条目。媒体提交前重新校验源摘要;视频后续派生使用 committed ledger 的稳定 `objectKey`,不把 `assetObjectId` 当远端输入;版本 journal 重放返回原子版本,禁止重复追加。
- 画布生成中冻结 wheel、指针、缩放和方向键状态改变并聚焦“停止等待”;普通 wheel 二维平移,Shift 纵向滚轮横移,Ctrl/Command wheel 缩放。Shift 指针序列只执行一次选择切换,草稿保存失败后退出仍必须提供保留/放弃确认。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs``apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``packages/image-canvas-core/src/interaction.ts`
## 2026-08-06 编辑器生成结果使用 durable receipt 与统一原子提交
- 背景:图片、改图、去背景、图集 / UI 多产物、角色动作、视频、音效和背景音乐在 OSS 结果可用后,仍分段 confirm object、创建 project resource / account asset、保存 canvas 和 complete job。任一中间失败都会留下部分业务事实;只把 `external_generation_job` 当 operation journal 又无法覆盖无 job 的 inline,也无法独立证明某批 resource/asset/canvas 已作为一笔提交完成。
@@ -4326,7 +4326,7 @@
- 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。
- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把 endpoint、精确请求体字节、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。`accepted` 只恢复 GET`prepared` 或提交响应丢失时,只允许校验账本身份、配置指纹和请求 SHA 后,以账本保存的原 endpoint、原始正文与同一键恢复同一逻辑 POST,不得重建画布上下文、重组正文或换键。恢复 `202` 后继续 GET,恢复再次 transport 失败仍保留原账本;轮询超时只保留既有 operation 并恢复 GET。game-chat 的 4500 秒硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey``202` 缺 operationId、状态损坏与 `postprocess-failed-source-preserved` 仍进入对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。这是 External v1 的专用幂等恢复,不是通用副作用自动重放。
- 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。
- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时恢复 POST 和 GET 都必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 才可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。
- 加固:durable snapshot 必须绑定不含明文凭据的规范 base URL 服务身份指纹;服务地址漂移时恢复 POST 和 GET 都必须阻断Developer API Key 轮换则必须继续原 operation。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。只有首次提交直接返回契约明确的 `400 / 401 / 403` 才可证明未入队并清理 prepared 账本;首次结果已经未知后,恢复请求的临时鉴权错误、超时、冲突、限流、网关错误及其它意外状态均保留同一账本。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。
- 代理 DNS:Clash 等透明代理可能把公网对象存储域名解析到 RFC 2544 的 `198.18.0.0/15` fake-IP。下载器只对已通过鉴权 `objectKey` 或受控 legacy path 换签得到的 URL 接受“全部地址均位于该 benchmark 段”的窄例外;直接 URL、其它本机/私网地址、公私混合解析和重定向仍必须失败关闭,不能为了兼容代理整体移除 SSRF 校验。
- 验证:覆盖“服务端已入队但提交响应丢失”后两次 POST 的 endpoint、正文 bytes 与 `Idempotency-Key` 完全相同,原键重试仍返回同一 operation,最终只出现一份 completed result 和一次计费 / 写回;恢复再次 transport 失败或临时鉴权失败仍保留同一账本;换 owner 不可见;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。
- 关联:`server-rs/crates/api-server/src/external_generation.rs``server-rs/crates/api-server/src/external_mcp.rs``docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`
@@ -4624,3 +4624,10 @@
- 原因:pool 只按 LLM 凭据和路由复用进程,节点身份只用于进程内 thread map。任一 stdout framing、子进程退出或连接故障都会 drain 整个进程的 pending/turn router,使所有共享节点同时失去可信终态。
- 处理:pool key 必须包含 `projectId + agentId + sessionId + runId`,每个权威节点直接持有独立 app-server 子进程;同节点 turn 还要串行,不能向同一 thread 并发 `turn/start`。只发送当前 CLI schema 定义的字段;stderr 使用有界内存尾部并先脱敏再进入 Runner 诊断。
- 验证:至少两个节点并发各跑多轮,确认存在两个 app-server PID;终止其中一个后只有对应节点进入 reconciliation,另一个仍能收到 `turn/completed`。旧 AppData 缺 `agentMode` 且含非 Responses 路由时必须保留 `provider`,不能在项目自动恢复时批量失败。
## 资源编辑恢复不能依赖当前 UI 快照或旧 API Key 指纹(2026-08-10
- 现象:应用重启后,任务视频或项目版本的编辑 operation 无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化;目录中放入大量无关文件还能绕过 pending 扫描上限。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;配置指纹混入 Key;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;服务身份只哈希规范 endpoint。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
- 验证:覆盖任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换、401/403/404 保留 operation、源摘要漂移拒绝、committed 视频二次派生以及版本 journal exactly-once。
File diff suppressed because one or more lines are too long
@@ -988,7 +988,12 @@ confirmation-required
- External 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;所有 External 路由必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;修正 External Editor API Key 配置后只继续同一 operation。
- 全类型资源编辑私有账本必须冻结 `sourceAssetId / sourceMediaType / sourceAssetKind / producerTaskId / sourceVersionId` 源快照;新账本不得依赖 UI 仍停留在原资源,旧账本则只允许从当前 manifest、已完成任务产物、版本数组和受控扩展名兼容恢复。恢复结果仍要重新校验唯一来源、项目归属与媒体类型,不能把账本快照当成越权凭据。
- 私有账本的 External 配置身份只绑定规范服务地址,不绑定可轮换的 Developer API Key。升级前仍使用“服务地址 + Key”指纹的 Agent 生成账本,在原 Key 尚可用时首次恢复必须原子迁移为仅服务地址指纹;迁移后 Key 轮换不影响原 operation,无法证明服务地址未漂移的旧账本仍失败关闭。Key 轮换后,401/403/404、轮询超时或网关暂时失败都必须保留原 endpoint、请求字节、幂等键和 operation;恢复命令仅凭持久 `operationId` 查找账本,不允许前端重新拼请求或携带 Key。
- pending 恢复扫描的目录上限统计所有实际目录条目,包括未知文件、非 pending 账本和损坏条目;达到上限后停止扫描并返回安全错误,不能通过堆放无关文件绕过边界。已 committed 的账本不得出现在 pending 列表中。
- 派生媒体提交必须以账本冻结的源摘要再次校验源文件;摘要、媒体身份或版本父子关系变化时失败关闭。远端派生视频二次编辑必须复用已提交账本中的稳定 `objectKey`,不能把仅用于本地登记的 `assetObjectId` 当成 generation reference。版本 journal 重放只能返回既有子版本,不得重复追加。
- 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。
- 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。
- Tauri 画布普通滚轮按二维 delta 平移,Shift 加纵向滚轮作为横向平移回退,Ctrl/Command 加滚轮才围绕指针缩放;一次 Shift 指针事件序列只能切换一次选择。生成中必须把焦点移到“停止等待”,并阻止 wheel、指针拖拽、缩放快捷键和方向键继续改变画布,避免生成恢复期间写入新的 UI 快照。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;图片继续复用现有画布,其他类型使用同一资源编辑壳按能力分流,不能把视频、音频或版本强塞进图片图层模型。共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响编辑链路。
@@ -243,6 +243,40 @@ export function zoomViewportFromWheel({
});
}
export function resolveViewportFromWheel({
viewport,
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
}: {
viewport: CanvasViewport;
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
screenPoint: CanvasPoint;
}): { mode: 'pan' | 'zoom'; viewport: CanvasViewport } {
if (ctrlKey || metaKey) {
return {
mode: 'zoom',
viewport: zoomViewportFromWheel({ viewport, deltaY, screenPoint }),
};
}
const useShiftWheelHorizontalFallback = shiftKey && deltaX === 0;
return {
mode: 'pan',
viewport: panViewportFromWheel({
viewport,
deltaX: useShiftWheelHorizontalFallback ? deltaY : deltaX,
deltaY: useShiftWheelHorizontalFallback ? 0 : deltaY,
}),
};
}
export function moveViewportFromPan(
dragState: Extract<DragState, { kind: 'pan' }>,
pointer: CanvasPoint,
@@ -10,6 +10,7 @@ import {
panViewportFromWheel,
resizeCanvasLayerBounds,
resolvePointerSelection,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
selectLayersInsideMarquee,
transformCanvasLayers,
@@ -62,6 +63,43 @@ describe('shared image canvas core', () => {
});
});
it('shares pan, shift-pan and modifier-zoom wheel semantics', () => {
const viewport = { x: 20, y: 30, scale: 1 };
expect(
resolveViewportFromWheel({
viewport,
deltaX: 4,
deltaY: 10,
shiftKey: false,
ctrlKey: false,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}),
).toEqual({ mode: 'pan', viewport: { x: 16, y: 20, scale: 1 } });
expect(
resolveViewportFromWheel({
viewport,
deltaX: 0,
deltaY: 10,
shiftKey: true,
ctrlKey: false,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}),
).toEqual({ mode: 'pan', viewport: { x: 10, y: 30, scale: 1 } });
expect(
resolveViewportFromWheel({
viewport,
deltaX: 0,
deltaY: -10,
shiftKey: false,
ctrlKey: true,
metaKey: false,
screenPoint: { x: 100, y: 100 },
}).mode,
).toBe('zoom');
});
it('supports additive selection, marquee and multi-layer movement', () => {
expect(
resolvePointerSelection({
@@ -12,9 +12,8 @@ import {
getWorldPointFromClient,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
panViewportFromWheel,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
zoomViewportFromWheel,
} from '@genarrative/image-canvas-core';
import {
type Dispatch,
@@ -369,32 +368,22 @@ export function useImageCanvasViewportControls({
event.preventDefault();
if (!event.ctrlKey && !event.metaKey) {
const useShiftWheelHorizontalFallback =
event.shiftKey && event.deltaX === 0;
setViewport((currentViewport) =>
panViewportFromWheel({
viewport: currentViewport,
deltaX: useShiftWheelHorizontalFallback
? event.deltaY
: event.deltaX,
deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY,
}),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const screenPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setViewport((currentViewport) =>
zoomViewportFromWheel({
viewport: currentViewport,
deltaY: event.deltaY,
screenPoint,
}),
setViewport(
(currentViewport) =>
resolveViewportFromWheel({
viewport: currentViewport,
deltaX: event.deltaX,
deltaY: event.deltaY,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
screenPoint,
}).viewport,
);
},
[canvasViewportRef, setViewport],