支持画布编辑现有图片并保留原资源
Project CI / Repository checks (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Failing after 2m3s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Native shell tests (pull_request) Successful in 13m33s

禁用资源新增入口并将现有图片资源接入编辑画布

提示词精修生成新图片并保留原图、原素材与来源血缘

同步生成阶段草稿版本并支持鉴权失败后的原操作恢复

补齐草稿退出确认、键盘可访问性、回归测试与工程文档
This commit is contained in:
2026-08-10 11:56:13 +08:00
parent 138fb2eef4
commit ea8e74dc2e
13 changed files with 730 additions and 113 deletions
@@ -1513,6 +1513,45 @@ fn stage_asset_canvas_image_with_token_at(
})
}
fn rebind_asset_canvas_staged_image_revision_at(
root: &Path,
staged_image_token: &str,
expected_project_id: &str,
draft_id: &str,
expected_previous_revision: u64,
next_revision: u64,
) -> Result<(), String> {
validate_uuid_v4(staged_image_token, "stagedImageToken")?;
validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?;
validate_safe_revision(next_revision, "staging 新草稿 revision")?;
validate_asset_canvas_project_identity(root, expected_project_id)?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if draft.revision != next_revision {
return Err("staging 不能绑定到非当前草稿 revision".to_string());
}
let (mut metadata, _) = read_staged_image_locked(root, staged_image_token)?;
if metadata.project_id != manifest.project_id || metadata.draft_id != draft_id {
return Err("staging 图片与当前项目草稿身份不匹配".to_string());
}
if metadata.draft_revision == next_revision {
return Ok(());
}
if metadata.draft_revision != expected_previous_revision {
return Err("staging 图片 revision 已被其它流程推进".to_string());
}
metadata.draft_revision = next_revision;
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&format!("{ASSET_CANVAS_ROOT}/staging/{staged_image_token}/metadata.json"),
"素材画布 staging 元数据",
&metadata,
16 * 1024,
)
}
fn draft_media_relative_path(
draft_id: &str,
media_ref: &AssetCanvasMediaRef,
@@ -54,6 +54,7 @@ pub(crate) struct AssetCanvasGenerationProgressEvent {
pub(crate) draft_id: String,
pub(crate) intent_id: String,
pub(crate) generation_id: String,
pub(crate) draft_revision: u64,
pub(crate) phase: String,
pub(crate) progress: Option<u8>,
pub(crate) error_code: Option<String>,
@@ -191,6 +192,8 @@ struct AssetCanvasGenerationLedger {
commit_idempotency_key: String,
expected_host_revision: u64,
expected_draft_revision: u64,
#[serde(default)]
current_draft_revision: Option<u64>,
prompt: String,
aspect_ratio: String,
image_size: String,
@@ -210,6 +213,8 @@ struct AssetCanvasGenerationLedger {
poll_after_ms: Option<u64>,
remote_result: Option<PrivateRemoteResult>,
staged_image_token: String,
#[serde(default)]
staged_draft_revision: Option<u64>,
commit_result: Option<PrivateCommitResult>,
error_code: Option<String>,
created_at: u64,
@@ -332,6 +337,9 @@ fn generation_progress_event(
draft_id: ledger.draft_id.clone(),
intent_id: ledger.intent_id.clone(),
generation_id: ledger.generation_id.clone(),
draft_revision: ledger
.current_draft_revision
.unwrap_or(ledger.expected_draft_revision),
phase: phase.to_string(),
progress,
error_code,
@@ -402,6 +410,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<()
}
validate_safe_revision(ledger.expected_host_revision, "expectedHostRevision")?;
validate_safe_revision(ledger.expected_draft_revision, "expectedDraftRevision")?;
if let Some(revision) = ledger.current_draft_revision {
validate_safe_revision(revision, "currentDraftRevision")?;
if revision < ledger.expected_draft_revision {
return Err("素材画布私有生成账本草稿 revision 倒退".to_string());
}
}
if let Some(revision) = ledger.staged_draft_revision {
validate_safe_revision(revision, "stagedDraftRevision")?;
}
if ledger.prompt.is_empty()
|| ledger.prompt.chars().count() > 32_000
|| ledger.request_fingerprint.len() != 64
@@ -484,7 +501,7 @@ fn set_private_phase(
fn upsert_public_generation_record(
root: &Path,
ledger: &AssetCanvasGenerationLedger,
) -> Result<AssetCanvasGenerationRecord, String> {
) -> Result<(AssetCanvasGenerationRecord, u64), String> {
let phase =
public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
@@ -541,17 +558,38 @@ fn upsert_public_generation_record(
} else if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted {
draft.status = AssetCanvasDraftStatus::Generating;
}
draft.revision = draft
.revision
.checked_add(1)
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = now;
write_asset_canvas_draft_locked(root, &draft)?;
Ok(record)
Ok((record, draft.revision))
}
fn publish_public_phase(
root: &Path,
ledger: &AssetCanvasGenerationLedger,
ledger: &mut AssetCanvasGenerationLedger,
emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send),
) -> Result<AssetCanvasGenerationRecord, String> {
let record = upsert_public_generation_record(root, ledger)?;
synchronize_staged_image_revision(root, ledger)?;
let previous_staged_revision = ledger.staged_draft_revision;
let (record, draft_revision) = upsert_public_generation_record(root, ledger)?;
ledger.current_draft_revision = Some(draft_revision);
write_generation_ledger(root, ledger)?;
if let Some(previous_revision) = previous_staged_revision {
rebind_asset_canvas_staged_image_revision_at(
root,
&ledger.staged_image_token,
&ledger.project_id,
&ledger.draft_id,
previous_revision,
draft_revision,
)?;
ledger.staged_draft_revision = Some(draft_revision);
write_generation_ledger(root, ledger)?;
}
emit(generation_progress_event(
ledger,
public_phase_name(&record.phase),
@@ -561,6 +599,40 @@ fn publish_public_phase(
Ok(record)
}
fn synchronize_staged_image_revision(
root: &Path,
ledger: &mut AssetCanvasGenerationLedger,
) -> Result<bool, String> {
let (metadata, _) = match read_staged_image_locked(root, &ledger.staged_image_token) {
Ok(staged) => staged,
Err(error) if error == "素材画布 staging 元数据不存在" => return Ok(false),
Err(error) => return Err(error),
};
if metadata.project_id != ledger.project_id || metadata.draft_id != ledger.draft_id {
return Err("staging 图片与生成账本身份不匹配".to_string());
}
let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if metadata.draft_revision != draft.revision {
rebind_asset_canvas_staged_image_revision_at(
root,
&ledger.staged_image_token,
&ledger.project_id,
&ledger.draft_id,
metadata.draft_revision,
draft.revision,
)?;
}
let changed = ledger.current_draft_revision != Some(draft.revision)
|| ledger.staged_draft_revision != Some(draft.revision);
ledger.current_draft_revision = Some(draft.revision);
ledger.staged_draft_revision = Some(draft.revision);
if changed {
write_generation_ledger(root, ledger)?;
}
Ok(true)
}
fn normalized_request_fingerprint(
input: &GenerateAssetCanvasImageInput,
manifest: &GameCreationAppManifest,
@@ -738,6 +810,7 @@ fn validate_and_prepare_ledger(
commit_idempotency_key: input.commit_idempotency_key.clone(),
expected_host_revision: input.expected_host_revision,
expected_draft_revision: input.expected_draft_revision,
current_draft_revision: Some(input.expected_draft_revision),
prompt: prompt.to_string(),
aspect_ratio: input.aspect_ratio.clone(),
image_size: input.image_size.clone(),
@@ -756,6 +829,7 @@ fn validate_and_prepare_ledger(
poll_after_ms: None,
remote_result: None,
staged_image_token: Uuid::new_v4().to_string(),
staged_draft_revision: None,
commit_result: None,
error_code: None,
created_at: now,
@@ -1402,7 +1476,9 @@ fn committed_execution_from_private_result(
project_id: committed.project_id.clone(),
commit_id: committed.commit_id.clone(),
committed_project_revision: committed.committed_project_revision,
draft_revision: committed.draft_revision,
draft_revision: ledger
.current_draft_revision
.unwrap_or(committed.draft_revision),
host_revision: committed.host_revision.to_string(),
commit_status: committed.commit_status.clone(),
manifest,
@@ -1456,6 +1532,14 @@ fn sanitized_generation_error(code: &str) -> String {
}
}
fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String {
if reconciliation && code == "authentication-required" {
return "reconciliation-required: 登录已失效;请重新登录后使用原 operation 恢复"
.to_string();
}
sanitized_generation_error(code)
}
fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool {
matches!(
ledger.phase,
@@ -1637,7 +1721,7 @@ async fn classify_canvas_submit_error(
fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) {
if error.contains("authentication-required") {
return (false, "authentication-required");
return (true, "authentication-required");
}
if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") {
return (false, "insufficient-mud-points");
@@ -1652,6 +1736,16 @@ fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) {
(false, "generation-failed")
}
fn preserve_submit_reconciliation(
phase: &GenerationLedgerPhase,
code: &str,
reconciliation: bool,
) -> bool {
reconciliation
|| (code == "authentication-required"
&& phase == &GenerationLedgerPhase::ReconciliationRequired)
}
async fn reconcile_generation(
root: &Path,
mut ledger: AssetCanvasGenerationLedger,
@@ -1770,8 +1864,10 @@ async fn reconcile_generation(
let status = response.status();
if !status.is_success() {
let (code, reconciliation) = classify_canvas_submit_error(response, api_mode).await;
let reconciliation =
preserve_submit_reconciliation(&ledger.phase, code, reconciliation);
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_generation_error(code));
return Err(sanitized_classified_generation_error(code, reconciliation));
}
let submission = match response.json::<serde_json::Value>().await {
Ok(value) => value,
@@ -1783,7 +1879,7 @@ async fn reconcile_generation(
match classify_canvas_generation_initial_response(status, &submission, api_mode) {
Ok(CanvasGenerationInitialResponse::Completed(generated)) => {
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
generated
}
Ok(CanvasGenerationInitialResponse::Async(submission)) => {
@@ -1793,9 +1889,9 @@ async fn reconcile_generation(
ledger.operation_id = Some(operation_id);
ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission));
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
match wait_for_canvas_generation_result(
&client,
api_base_url,
@@ -1808,7 +1904,7 @@ async fn reconcile_generation(
Err(error) => {
let (reconciliation, code) = classify_canvas_generation_error(&error);
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_generation_error(code));
return Err(sanitized_classified_generation_error(code, reconciliation));
}
}
}
@@ -1827,14 +1923,14 @@ async fn reconcile_generation(
"pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000),
});
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await
{
Ok(result) => result,
Err(error) => {
let (reconciliation, code) = classify_canvas_generation_error(&error);
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_generation_error(code));
return Err(sanitized_classified_generation_error(code, reconciliation));
}
}
} else {
@@ -1863,7 +1959,7 @@ async fn reconcile_generation(
GenerationLedgerPhase::RemoteCompleted,
None,
)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
}
if matches!(
@@ -1871,13 +1967,7 @@ async fn reconcile_generation(
GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired
) && ledger.commit_result.is_none()
{
let staged_exists = read_staged_image_locked(root, &ledger.staged_image_token)
.ok()
.is_some_and(|(metadata, _)| {
metadata.project_id == ledger.project_id
&& metadata.draft_id == ledger.draft_id
&& metadata.draft_revision == ledger.expected_draft_revision
});
let staged_exists = synchronize_staged_image_revision(root, &mut ledger)?;
if !staged_exists {
let remote = ledger
.remote_result
@@ -1921,7 +2011,9 @@ async fn reconcile_generation(
project_path: root.to_string_lossy().into_owned(),
expected_project_id: ledger.project_id.clone(),
draft_id: ledger.draft_id.clone(),
expected_draft_revision: ledger.expected_draft_revision,
expected_draft_revision: ledger
.current_draft_revision
.unwrap_or(ledger.expected_draft_revision),
media_type: download.media_type,
bytes: download.bytes,
},
@@ -1939,6 +2031,9 @@ async fn reconcile_generation(
"download-reconciliation-required",
));
}
ledger.current_draft_revision = Some(staged.draft_revision);
ledger.staged_draft_revision = Some(staged.draft_revision);
write_generation_ledger(root, &mut ledger)?;
}
set_private_phase(
root,
@@ -1946,17 +2041,29 @@ async fn reconcile_generation(
GenerationLedgerPhase::MediaDownloaded,
None,
)?;
publish_public_phase(root, &ledger, emit)?;
publish_public_phase(root, &mut ledger, emit)?;
}
if ledger.phase == GenerationLedgerPhase::MediaDownloaded {
if !synchronize_staged_image_revision(root, &mut ledger)? {
mark_generation_error(
root,
&mut ledger,
true,
"commit-reconciliation-required",
emit,
)?;
return Err(sanitized_generation_error("commit-reconciliation-required"));
}
let commit = commit_asset_canvas_at(
root,
&CommitAssetCanvasInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: ledger.project_id.clone(),
expected_revision: ledger.expected_host_revision,
expected_draft_revision: ledger.expected_draft_revision,
expected_draft_revision: ledger
.current_draft_revision
.unwrap_or(ledger.expected_draft_revision),
draft_id: ledger.draft_id.clone(),
commit_id: ledger.commit_id.clone(),
idempotency_key: ledger.commit_idempotency_key.clone(),
@@ -2042,6 +2149,7 @@ async fn reconcile_generation(
return Err(sanitized_generation_error("commit-reconciliation-required"));
}
};
ledger.current_draft_revision = Some(private_commit.draft_revision);
ledger.commit_result = Some(private_commit);
set_private_phase(
root,
@@ -2049,7 +2157,14 @@ async fn reconcile_generation(
GenerationLedgerPhase::AssetDurableCommitted,
None,
)?;
let generation = publish_public_phase(root, &ledger, emit)?;
let generation = publish_public_phase(root, &mut ledger, emit)?;
let final_draft_revision = ledger
.current_draft_revision
.expect("public commit projection must advance the draft revision");
if let Some(committed) = ledger.commit_result.as_mut() {
committed.draft_revision = final_draft_revision;
}
write_generation_ledger(root, &mut ledger)?;
let committed = ledger
.commit_result
.as_ref()
@@ -2065,7 +2180,7 @@ async fn reconcile_generation(
project_id: committed.project_id.clone(),
commit_id: committed.commit_id.clone(),
committed_project_revision: committed.committed_project_revision,
draft_revision: committed.draft_revision,
draft_revision: final_draft_revision,
host_revision: committed.host_revision.to_string(),
commit_status: committed.commit_status.clone(),
manifest,
@@ -2357,6 +2472,7 @@ mod tests {
commit_idempotency_key: Uuid::new_v4().to_string(),
expected_host_revision: 0,
expected_draft_revision: draft.revision,
current_draft_revision: Some(draft.revision),
prompt: "重启前已经提交的私有正文".to_string(),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
@@ -2381,6 +2497,7 @@ mod tests {
poll_after_ms: Some(0),
remote_result: None,
staged_image_token: Uuid::new_v4().to_string(),
staged_draft_revision: None,
commit_result: None,
error_code: None,
created_at: now,
@@ -2504,6 +2621,27 @@ mod tests {
assert!(progress
.iter()
.any(|event| event.phase == "asset-durable-committed"));
assert!(progress
.windows(2)
.all(|events| events[0].draft_revision < events[1].draft_revision));
assert_eq!(
progress.last().map(|event| event.draft_revision),
Some(first.result.commit.draft_revision)
);
let private = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read committed private ledger")
.expect("committed private ledger exists");
assert_eq!(
private.current_draft_revision,
Some(first.result.commit.draft_revision)
);
assert_eq!(
private.staged_draft_revision,
Some(first.result.commit.draft_revision)
);
let (staged, _) = read_staged_image_locked(directory.path(), &private.staged_image_token)
.expect("read committed staging image");
assert_eq!(staged.draft_revision, first.result.commit.draft_revision);
let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(requests.len(), 6);
@@ -2716,6 +2854,7 @@ mod tests {
commit_idempotency_key: Uuid::new_v4().to_string(),
expected_host_revision: 0,
expected_draft_revision: 0,
current_draft_revision: Some(0),
prompt: "精修提示词".to_string(),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
@@ -2761,6 +2900,7 @@ mod tests {
poll_after_ms: None,
remote_result: None,
staged_image_token: Uuid::new_v4().to_string(),
staged_draft_revision: None,
commit_result: None,
error_code: None,
created_at: now,
@@ -2869,6 +3009,53 @@ mod tests {
drop(directory);
}
#[test]
fn accepted_authentication_failure_stays_recoverable_with_the_original_operation() {
let project_id = "phase-five-auth-reconciliation";
let (directory, draft) = create_generation_fixture(project_id, "阶段五登录恢复语义测试");
let mut ledger = accepted_ledger(
project_id,
&draft,
"https://editor.example.test",
"private-key",
);
let original_operation = ledger.operation_id.clone();
let original_request = ledger.request_body_json.clone();
let (reconciliation, code) =
classify_canvas_generation_error("authentication-required: 登录已失效");
assert!(reconciliation);
assert_eq!(code, "authentication-required");
let mut progress = Vec::new();
mark_generation_error(
directory.path(),
&mut ledger,
reconciliation,
code,
&mut |event| progress.push(event),
)
.expect("mark authentication reconciliation");
assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired);
assert_eq!(ledger.operation_id, original_operation);
assert_eq!(ledger.request_body_json, original_request);
assert!(should_poll_existing_operation(&ledger));
assert_eq!(progress.len(), 1);
assert_eq!(progress[0].phase, "reconciliation-required");
assert_eq!(progress[0].error_code.as_deref(), Some(code));
assert!(sanitized_classified_generation_error(code, reconciliation)
.starts_with("reconciliation-required:"));
assert!(!preserve_submit_reconciliation(
&GenerationLedgerPhase::Prepared,
code,
false,
));
assert!(preserve_submit_reconciliation(
&GenerationLedgerPhase::ReconciliationRequired,
code,
false,
));
}
#[test]
fn authenticated_access_token_is_ephemeral_and_never_enters_generation_ledger() {
let project_id = "phase-five-authenticated-token";
@@ -93,6 +93,11 @@ export type AssetCanvasSaveAttempt = {
commitId: string;
};
export type AssetCanvasExitResult = {
draftId: string;
disposition: 'kept' | 'discarded';
};
export type AssetCanvasCommitNotification = {
source: 'command' | 'event';
projectPath: string;
@@ -323,7 +328,7 @@ export function AssetCanvasSurface({
expectedHostRevision: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: () => void;
onCancel?: (result: AssetCanvasExitResult) => void;
onCommitted?: (input: AssetCanvasCommitNotification) => void;
onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void;
renderImage?: RenderAssetCanvasImage;
@@ -369,6 +374,8 @@ export function AssetCanvasSurface({
>('1K');
const [generationReferenceResourceIds, setGenerationReferenceResourceIds] =
useState<string[]>([]);
const [exitDialogOpen, setExitDialogOpen] = useState(false);
const [exitActionPending, setExitActionPending] = useState(false);
const [documentVersion, setDocumentVersion] = useState(0);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const viewportElementRef = useRef<HTMLDivElement | null>(null);
@@ -418,6 +425,26 @@ export function AssetCanvasSurface({
setLifecycle({ kind: 'canvas.editing', dirty: true });
}, []);
const applyGenerationProgressRevision = useCallback(
(progress: ImageCanvasGenerationProgress) => {
const revision = progress.draftRevision;
const currentDraft = draftRef.current;
if (
typeof revision !== 'number' ||
!Number.isSafeInteger(revision) ||
revision < 0 ||
!currentDraft ||
revision <= currentDraft.revision
) {
return;
}
const nextDraft = { ...currentDraft, revision };
draftRef.current = nextDraft;
setDraft(nextDraft);
},
[],
);
const canvasHistoryRefs = useMemo(
() => ({
layersRef,
@@ -504,6 +531,7 @@ export function AssetCanvasSurface({
}),
);
if (epoch !== epochRef.current) return;
draftRef.current = nextDraft;
setDraft(nextDraft);
setLayers(runtimeLayers);
setViewport(nextDraft.canvas.viewport);
@@ -609,6 +637,7 @@ export function AssetCanvasSurface({
epoch === epochRef.current &&
recoveryFocusEpoch === generationFocusEpochRef.current
) {
applyGenerationProgressRevision(progress);
setNotice(`正在恢复图片生成:${progress.phase}`);
}
},
@@ -650,6 +679,7 @@ export function AssetCanvasSurface({
previewUrls.clear();
};
}, [
applyGenerationProgressRevision,
expectedHostRevision,
host,
hydrateDraft,
@@ -1057,11 +1087,12 @@ export function AssetCanvasSurface({
stableScope,
]);
const cancelCanvas = useCallback(() => {
const discardCanvas = useCallback(() => {
const currentDraft = draftRef.current;
if (!currentDraft || lifecycleRef.current.kind === 'canvas.saving') {
return;
}
setExitActionPending(true);
const epoch = epochRef.current;
void host.project
.discardDraft({
@@ -1071,7 +1102,11 @@ export function AssetCanvasSurface({
.then((result) => {
if (epoch !== epochRef.current) return;
if (result.status === 'ok') {
onCancel?.();
setExitDialogOpen(false);
onCancel?.({
draftId: stableScope.draftId,
disposition: 'discarded',
});
return;
}
setLifecycle({
@@ -1092,9 +1127,54 @@ export function AssetCanvasSurface({
message: error instanceof Error ? error.message : String(error),
reconciliationRequired: false,
});
})
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [host.project, onCancel, stableScope]);
const keepDraftAndExit = useCallback(() => {
if (exitActionPending) return;
const currentDraft = draftRef.current;
if (!currentDraft) return;
setExitActionPending(true);
const epoch = epochRef.current;
void persistDraft()
.then((persisted) => {
if (epoch !== epochRef.current || !persisted) return;
setExitDialogOpen(false);
onCancel?.({
draftId: stableScope.draftId,
disposition: 'kept',
});
})
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [exitActionPending, onCancel, persistDraft, stableScope.draftId]);
const requestCanvasExit = useCallback(() => {
const currentDraft = draftRef.current;
if (
!currentDraft ||
lifecycleRef.current.kind === 'canvas.saving' ||
lifecycleRef.current.kind === 'canvas.generating'
) {
return;
}
if (
lifecycleRef.current.kind === 'canvas.editing' &&
lifecycleRef.current.dirty
) {
setExitDialogOpen(true);
return;
}
onCancel?.({
draftId: stableScope.draftId,
disposition: 'kept',
});
}, [onCancel, stableScope.draftId]);
const openGenerationDialog = useCallback(() => {
const currentDraft = draftRef.current;
if (
@@ -1191,6 +1271,7 @@ export function AssetCanvasSurface({
progress.intentId === identity.intentId &&
progress.generationId === identity.generationId
) {
applyGenerationProgressRevision(progress);
setLifecycle({ kind: 'canvas.generating', phase: progress.phase });
setNotice(progress.errorCode ?? '');
}
@@ -1287,6 +1368,7 @@ export function AssetCanvasSurface({
}
});
}, [
applyGenerationProgressRevision,
assetKind,
assetName,
generationAspectRatio,
@@ -1327,8 +1409,11 @@ export function AssetCanvasSurface({
pendingGenerationRef.current = null;
setGenerationDialog(null);
setNotice('生成仍会在后台使用原 operation 安全对账');
onCancel?.();
}, [onCancel]);
onCancel?.({
draftId: stableScope.draftId,
disposition: 'kept',
});
}, [onCancel, stableScope.draftId]);
const minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
@@ -1379,7 +1464,7 @@ export function AssetCanvasSurface({
onClick={
lifecycle.kind === 'canvas.generating'
? stopWaitingForGeneration
: cancelCanvas
: requestCanvasExit
}
disabled={lifecycle.kind === 'canvas.saving'}
>
@@ -1678,6 +1763,54 @@ export function AssetCanvasSurface({
</div>
) : null}
{exitDialogOpen ? (
<div
className="asset-canvas-surface__dialog-backdrop"
role="presentation"
>
<section
className="asset-canvas-surface__generation-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="asset-canvas-exit-title"
>
<header>
<strong id="asset-canvas-exit-title">返回资源总览</strong>
</header>
<div className="asset-canvas-surface__generation-confirmation">
<p>当前画布有尚未完成的编辑,请选择是否保留草稿。</p>
<footer>
<CanvasChromeButton
label="放弃草稿"
icon={<Trash2 size={15} aria-hidden="true" />}
onClick={discardCanvas}
disabled={exitActionPending}
>
放弃草稿
</CanvasChromeButton>
<CanvasChromeButton
label="继续编辑"
icon={<ArrowLeft size={15} aria-hidden="true" />}
onClick={() => setExitDialogOpen(false)}
disabled={exitActionPending}
>
继续编辑
</CanvasChromeButton>
<CanvasChromeButton
label="保留草稿并退出"
icon={<Save size={15} aria-hidden="true" />}
className="asset-canvas-surface__primary-action"
onClick={keepDraftAndExit}
disabled={exitActionPending}
>
保留草稿并退出
</CanvasChromeButton>
</footer>
</div>
</section>
</div>
) : null}
{lifecycle.kind === 'canvas.generating' ? (
<section
className="asset-canvas-surface__operation-overlay"
@@ -1722,7 +1855,7 @@ export function AssetCanvasSurface({
<CanvasChromeButton
label="返回资源总览"
icon={<ArrowLeft size={15} aria-hidden="true" />}
onClick={cancelCanvas}
onClick={requestCanvasExit}
>
返回资源总览
</CanvasChromeButton>
@@ -1791,60 +1924,84 @@ export function AssetCanvasSurface({
return (
<LayerRenderer
key={layer.id}
as="div"
layer={layer}
selected={selected}
role="group"
aria-label={`图层 ${layer.title}`}
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
event.stopPropagation();
const targetIds = event.shiftKey
? selected
? selectedLayerIds.filter((id) => id !== layer.id)
: [...selectedLayerIds, layer.id]
: selected
? selectedLayerIds
>
<button
type="button"
className="asset-canvas-surface__layer-hit-target"
aria-label={`选择图层 ${layer.title}`}
aria-pressed={selected}
onClick={(event) => {
const targetIds = event.shiftKey
? selected
? selectedLayerIds.filter((id) => id !== layer.id)
: [...selectedLayerIds, layer.id]
: [layer.id];
setSelectedLayerIds(targetIds);
if (layer.locked) {
if (
targetIds.length !== selectedLayerIds.length ||
targetIds.some(
(id, index) => id !== selectedLayerIds[index],
)
) {
setSelectedLayerIds(targetIds);
markDirty();
}
return;
}
captureHistory({
type: 'move-image',
count: targetIds.length,
layerIds: targetIds,
});
dragRef.current = {
kind: 'move',
startClientX: event.clientX,
startClientY: event.clientY,
startLayers: layersRef.current.map((item) => ({ ...item })),
targetIds,
};
}}
>
<img
src={layer.src}
alt=""
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
transform: `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`,
pointerEvents: 'none',
}}
/>
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
const targetIds = event.shiftKey
? selected
? selectedLayerIds.filter((id) => id !== layer.id)
: [...selectedLayerIds, layer.id]
: selected
? selectedLayerIds
: [layer.id];
setSelectedLayerIds(targetIds);
if (layer.locked) {
if (
targetIds.length !== selectedLayerIds.length ||
targetIds.some(
(id, index) => id !== selectedLayerIds[index],
)
) {
markDirty();
}
return;
}
captureHistory({
type: 'move-image',
count: targetIds.length,
layerIds: targetIds,
});
dragRef.current = {
kind: 'move',
startClientX: event.clientX,
startClientY: event.clientY,
startLayers: layersRef.current.map((item) => ({ ...item })),
targetIds,
};
}}
>
<img
src={layer.src}
alt=""
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
transform: `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`,
pointerEvents: 'none',
}}
/>
</button>
{selected && !layer.locked ? (
<span
role="button"
tabIndex={0}
<button
type="button"
aria-label={`缩放图层 ${layer.title}`}
className="asset-canvas-surface__resize-handle"
onPointerDown={(event) => {
@@ -1864,6 +2021,35 @@ export function AssetCanvasSurface({
layerId: layer.id,
};
}}
onKeyDown={(event) => {
if (!event.key.startsWith('Arrow')) return;
event.preventDefault();
const direction =
event.key === 'ArrowRight' || event.key === 'ArrowDown'
? 1
: -1;
const delta = direction * (event.shiftKey ? 10 : 1);
captureHistory({
type: 'resize-image',
count: 1,
layerIds: [layer.id],
});
const resized = resizeCanvasLayerBounds({
initial: layer,
handle: 'bottom-right',
deltaX: delta,
deltaY: delta,
preserveAspectRatio: true,
minSize: 16,
});
setLayers((current) =>
transformCanvasLayers(
current,
new Map([[layer.id, resized]]),
) as RuntimeCanvasLayer[],
);
markDirty();
}}
/>
) : null}
</LayerRenderer>
@@ -333,6 +333,24 @@
cursor: nwse-resize;
}
.asset-canvas-surface__layer-hit-target {
display: block;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: inherit;
cursor: inherit;
}
.asset-canvas-surface__layer-hit-target:focus-visible,
.asset-canvas-surface__resize-handle:focus-visible {
outline: 3px solid var(--platform-focus-ring);
outline-offset: 3px;
}
.asset-canvas-surface__viewport-tools {
position: absolute;
z-index: 5;
@@ -255,6 +255,7 @@ export function createTauriImageCanvasHostAdapter(input: {
callback({
intentId: progress.intentId,
generationId: progress.generationId,
draftRevision: progress.draftRevision,
phase: progress.phase,
progress: progress.progress,
errorCode: progress.errorCode,
@@ -40,6 +40,7 @@ import type {
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
type AssetCanvasCommitNotification,
type AssetCanvasExitResult,
type AssetCanvasSaveAttempt,
AssetCanvasSurface,
} from '../../features/asset-canvas/AssetCanvasSurface';
@@ -498,6 +499,7 @@ export default function ProjectDevelopmentView({
const focusedCommitIdsRef = useRef(new Set<string>());
const activeFocusFlowIdRef = useRef<string | null>(null);
const assetCanvasRouteRef = useRef<AssetCanvasRoute | null>(null);
const resumableRefineDraftsRef = useRef(new Map<string, string>());
const dependencyDescriptionId = useId();
assetCanvasRouteRef.current = assetCanvasRoute;
@@ -1168,21 +1170,30 @@ export default function ProjectDevelopmentView({
);
const openAssetCanvas = useCallback(
(intent: 'create' | 'refine', sourceAssetId: string | null) => {
(sourceAssetId: string) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setAssetCanvasNotice('素材创作画布需要在客户端内打开');
return;
}
const sourceAsset = manifest.assets.find(
(asset) => asset.id === sourceAssetId,
);
if (
!sourceAsset ||
!['image/png', 'image/jpeg', 'image/webp'].includes(
sourceAsset.mediaType,
)
) {
setAssetCanvasNotice('只能编辑当前项目中已存在的图片资源');
return;
}
const openEpoch = canvasOpenEpochRef.current + 1;
canvasOpenEpochRef.current = openEpoch;
advanceFocusGeneration();
pendingResourceFocusRef.current = null;
setHiddenCommittedResourceId(null);
setAssetCanvasNotice('正在打开素材创作画布…');
const sourceAsset = sourceAssetId
? manifest.assets.find((asset) => asset.id === sourceAssetId)
: null;
void invoke<{ revision: number }>('get_local_game_project_revision', {
projectPath,
})
@@ -1198,8 +1209,10 @@ export default function ProjectDevelopmentView({
const sessionId = crypto.randomUUID();
const scope: ImageCanvasHostScope = {
projectId: manifest.projectId,
draftId: crypto.randomUUID(),
intent,
draftId:
resumableRefineDraftsRef.current.get(sourceAssetId) ??
crypto.randomUUID(),
intent: 'refine',
sourceAssetId,
};
activeFocusFlowIdRef.current = flowId;
@@ -1207,14 +1220,8 @@ export default function ProjectDevelopmentView({
flowId,
sessionId,
expectedHostRevision: String(status.revision),
initialAssetName:
intent === 'refine' && sourceAsset
? assetNameFromLocalPath(sourceAsset.localPath)
: '画布素材',
initialAssetKind:
intent === 'refine' && sourceAsset
? sourceAsset.kind
: 'game-art',
initialAssetName: assetNameFromLocalPath(sourceAsset.localPath),
initialAssetKind: sourceAsset.kind,
scope,
host: createTauriImageCanvasHostAdapter({
projectPath,
@@ -1236,7 +1243,16 @@ export default function ProjectDevelopmentView({
[advanceFocusGeneration, manifest.assets, manifest.projectId, projectPath],
);
const cancelAssetCanvas = useCallback(() => {
const cancelAssetCanvas = useCallback((result: AssetCanvasExitResult) => {
const route = assetCanvasRouteRef.current;
const sourceAssetId = route?.scope.sourceAssetId;
if (sourceAssetId) {
if (result.disposition === 'kept') {
resumableRefineDraftsRef.current.set(sourceAssetId, result.draftId);
} else {
resumableRefineDraftsRef.current.delete(sourceAssetId);
}
}
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
activeFocusFlowIdRef.current = null;
@@ -1307,6 +1323,9 @@ export default function ProjectDevelopmentView({
...focusIntent,
resourceId: `asset:${notification.assetId}`,
};
if (route.scope.sourceAssetId) {
resumableRefineDraftsRef.current.delete(route.scope.sourceAssetId);
}
setAssetCanvasRoute(null);
setMode('resources');
setFocusedResourceId(null);
@@ -1518,7 +1537,8 @@ export default function ProjectDevelopmentView({
<>
<button
type="button"
onClick={() => openAssetCanvas('create', null)}
disabled
title="新增资源暂未开放,请先选择现有图片进行编辑"
>
<Sparkles size={15} aria-hidden="true" />
新增资源
@@ -1604,11 +1624,9 @@ export default function ProjectDevelopmentView({
{focusedResourceIsImage && focusedResource.manifestAssetId ? (
<button
type="button"
onClick={() =>
openAssetCanvas('refine', focusedResource.manifestAssetId)
}
onClick={() => openAssetCanvas(focusedResource.manifestAssetId!)}
>
精修资源
编辑资源
</button>
) : null}
<button
@@ -14,6 +14,7 @@ import {
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -61,6 +62,38 @@ function emptyCanvas(): ImageCanvasDraftCanvas {
};
}
function keyboardCanvas(): ImageCanvasDraftCanvas {
return {
...emptyCanvas(),
layers: ['第一层', '第二层'].map((title, index) => ({
layerId: `keyboard-layer-${index + 1}`,
resourceId: `draft-media:keyboard-${index + 1}`,
title,
mediaRef: {
kind: 'draft-media' as const,
mediaId: `keyboard-${index + 1}`,
mediaType: 'image/png' as const,
sha256: String(index + 1).repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: index * 60,
y: 0,
width: 40,
height: 30,
originalWidth: 40,
originalHeight: 30,
zIndex: index,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
})),
};
}
function draftFixture(
draftScope: ImageCanvasHostScope = scope,
canvas: ImageCanvasDraftCanvas = emptyCanvas(),
@@ -160,6 +193,11 @@ function memoryHost(input?: {
value: { projectRevision: hostRevision, manifest },
};
});
const discardDraft = vi.fn(async () => {
if (!draft) throw new Error('draft missing');
draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' };
return { status: 'ok' as const, value: draft };
});
const host: TauriImageCanvasHostAdapter = {
kind: 'tauri',
projectPath: '/fixture/project',
@@ -198,11 +236,7 @@ function memoryHost(input?: {
};
return { status: 'ok', value: draft };
},
async discardDraft() {
if (!draft) throw new Error('draft missing');
draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' };
return { status: 'ok', value: draft };
},
discardDraft,
},
asset: {
async importImages(importInput) {
@@ -362,6 +396,7 @@ function memoryHost(input?: {
revokedSubscriptions,
loadDraft,
recover,
discardDraft,
getDraft: () => draft,
emit(event: LocalAssetCommittedEvent) {
eventListener?.(event);
@@ -592,15 +627,97 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
);
});
it('取消时先把草稿标记为 cancelled,再返回资源总览', async () => {
it('无未保存修改时保留草稿并直接返回资源总览', async () => {
const memory = memoryHost();
const { onCancel } = renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
});
expect(memory.discardDraft).not.toHaveBeenCalled();
expect(memory.getDraft()?.status).toBe('editing');
});
it('有未保存修改时使用独立确认面板,并可保存草稿后退出', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const { onCancel } = renderSurface(memory.host);
const layer = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(layer);
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
screen.getByRole('dialog', { name: '返回资源总览' }),
).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '保留草稿并退出' }));
await waitFor(() =>
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
}),
);
expect(memory.updates.length).toBeGreaterThan(0);
expect(memory.discardDraft).not.toHaveBeenCalled();
});
it('只有明确放弃草稿时才写入 cancelled', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const { onCancel } = renderSurface(memory.host);
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 第一层' }),
);
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
fireEvent.click(screen.getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(memory.discardDraft).toHaveBeenCalledTimes(1));
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'discarded',
});
expect(memory.getDraft()?.status).toBe('cancelled');
});
it('原生图层按钮支持 Enter 和空格选择,缩放按钮支持方向键', async () => {
const user = userEvent.setup();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
first.focus();
await user.keyboard('{Enter}');
expect(first.getAttribute('aria-pressed')).toBe('true');
second.focus();
await user.keyboard(' ');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(first.getAttribute('aria-pressed')).toBe('false');
const resize = screen.getByRole('button', { name: '缩放图层 第二层' });
resize.focus();
await user.keyboard('{ArrowRight}');
await waitFor(() => {
const resized = memory
.getDraft()
?.canvas.layers.find((layer) => layer.layerId === 'keyboard-layer-2');
expect(resized?.width).toBeGreaterThan(40);
});
});
it('新建、导入、编辑、撤销重做并完成 durable commit', async () => {
const memory = memoryHost();
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
@@ -982,6 +1099,10 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
screen.getAllByRole('button', { name: '停止等待并返回' }).at(-1)!,
);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
});
await act(async () => gate.resolve());
await act(async () => Promise.resolve());
@@ -1060,7 +1181,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) });
const onCommitted = vi.fn();
const view = renderSurface(memory.host, { ...scope }, onCommitted);
const layer = await screen.findByLabelText('图层 锁定图层');
const layer = await screen.findByRole('button', {
name: '选择图层 锁定图层',
});
expect(memory.recover).toHaveBeenCalledTimes(1);
expect(memory.loadDraft).toHaveBeenCalledTimes(1);
view.rerender(
@@ -23,7 +23,10 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
sessionId: string;
initialAssetName?: string;
initialAssetKind?: string;
onCancel?: () => void;
onCancel?: (result: {
draftId: string;
disposition: 'kept' | 'discarded';
}) => void;
onSaveAttempt?: (attempt: {
saveAttemptId: string;
sessionId: string;
@@ -43,7 +46,14 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
),
ReactModule.createElement(
'button',
{ type: 'button', onClick: props.onCancel },
{
type: 'button',
onClick: () =>
props.onCancel?.({
draftId: props.scope.draftId,
disposition: 'kept',
}),
},
'取消并返回',
),
ReactModule.createElement(
@@ -266,15 +276,20 @@ describe('project resource live canvas integration', () => {
return { graphReads, layoutWrites };
}
it('enters create/refine in the central view, cancels to context, and coordinates both layouts after a durable refine', async () => {
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
const { graphReads, layoutWrites } = installTauri();
render(<LiveWorkbench />);
const sourceCard = await screen.findByRole('button', {
name: /source-art\.png/,
});
expect(
(screen.getByRole('button', {
name: '新增资源',
}) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(sourceCard);
fireEvent.click(screen.getByRole('button', { name: '精修资源' }));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art:source-art:art-image');
@@ -283,7 +298,7 @@ describe('project resource live canvas integration', () => {
await screen.findByRole('region', { name: /source-art\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '精修资源' }));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
@@ -291,6 +306,9 @@ describe('project resource live canvas integration', () => {
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', { name: /source-art\.png/ }),
).not.toBeNull();
expect(
await screen.findByText(/canvas-output-1\.png 引用 source-art\.png/),
).not.toBeNull();
@@ -328,12 +346,15 @@ describe('project resource live canvas integration', () => {
).toBe(true);
});
it('keeps an existing search when the new resource is hidden and locates only after the explicit action', async () => {
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
installTauri();
render(<LiveWorkbench />);
const search = await screen.findByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'source-art' } });
fireEvent.click(screen.getByRole('button', { name: '新增资源' }));
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
@@ -85,7 +85,7 @@
### 3.7 主站 UI 对齐与共享视觉边界
实现状态(2026-08-06):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义未随换肤改变。
实现状态(2026-08-10):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。当前普通用户入口临时收敛为仅编辑现有图片:“新增资源”不可进入 create,图片“编辑资源”继续使用 refine 非破坏性生成新 asset。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义未随入口收敛而改变。
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`。
@@ -102,8 +102,8 @@
```text
resource-overview
-> asset-canvas.create(点击“新增资源”)
-> asset-canvas.refine(在唯一图片资源上点击“精修资源”)
-> asset-canvas.create(当前临时禁用,不向普通用户开放)
-> asset-canvas.refine(在唯一图片资源上点击“编辑资源”)
-> run(存在 runnableVersion 且 loopback preview 可启动)
asset-canvas.create|refine
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-10 客户端素材画布临时收敛为现有图片非破坏性编辑
- 产品决策:资源总览“新增资源”暂时禁用,普通用户只从唯一图片资源进入“编辑资源”。底层 create 草稿和兼容测试继续保留,不删除既有合同,后续恢复入口时不需要重建数据层。
- 编辑语义:refine 自动绑定源图片并调用现有 `/api/editor/images/edits`;结果创建独立 asset 和文件,源 asset/文件保持不变,新资源以 `referenceResourceIds` 登记源资源血缘。
- 可靠性决策:公开生成状态每次落盘推进草稿 revision,并同步私有账本、进度事件、staging 和 commit;已受理或结果未知的 operation 遇到鉴权失效进入可恢复对账态,刷新登录后继续原 operation,不创建替代任务。
- 交互决策:dirty 返回必须通过独立确认面板选择保留或放弃,默认保留并先 flush;图层选择和缩放同时提供指针与键盘路径,禁止嵌套交互元素。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`。
## 2026-08-06 Game Agent 画布视觉以主站 token 与共享 chrome 为唯一来源
- 背景:网站与 Tauri 已经共同消费 `image-canvas-core/react` 的 viewport、selection、renderer 和 history,但客户端素材画布仍维护独立的文字工具栏、按钮和状态外观,`1280×800` 下会出现动作逐字换行、主次不清和工作台四区视觉漂移。直接复制主站 `src/components/image-editor` 或整包 `src/index.css` 会重新形成宿主分叉和隐式全局依赖。
@@ -379,7 +379,7 @@ game-project/
2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `resource-overview / asset-canvas / run` 三种状态。资源总览的“新增资源”和图片聚焦的“精修资源”进入 create/refine 素材创作无限画布;素材画布只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 中间主视窗提供 `resource-overview / asset-canvas / run` 三种状态。2026-08-10 起普通用户入口临时收敛为仅编辑现有图片:“新增资源”显示为禁用态且处理函数拒绝 create,图片聚焦的“编辑资源”进入 refine 素材创作无限画布;底层 create 合同仅保留兼容。素材画布只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
@@ -1,8 +1,8 @@
# 客户端素材创作无限画布阶段一合同
更新时间:`2026-08-06`
更新时间:`2026-08-10`
状态:阶段一产品与技术合同已冻结;截至 2026-08-06,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地,后续增量仍受本文合同约束。
状态:阶段一产品与技术合同已冻结;截至 2026-08-10,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节临时收敛为仅编辑现有图片,后续增量仍受本文合同约束。
本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。
@@ -968,3 +968,18 @@ confirmation-required
- 阶段五定向测试覆盖共享 chrome 的 pressed、Tauri 实际消费的工具组/分隔符、按钮名称、expanded/disabled、生成确认、失败重试、保存/取消,以及生成 dialog、进度/失败卡、状态栏、Supervisor composer 和 Agent Dock 的非裁剪/可见性合同。生成请求、登录态、泥点计费、Host Port、草稿 CAS、manifest/revision、Runtime、审批和保存事务均未改变。
- 保存设置的产品语义固定为“名称可编辑、用途受控、格式枚举”。create 默认 `game-art`,普通用户只从 `game-art / icon-spec / ui-prototype / art-spritesheet` 四个权威用途选择;界面显示中文名称,不暴露自由 slug 输入。refine 必须继承源 manifest asset 的 `kind` 并锁定,未知历史 kind 原值透传但只显示“原资源用途”,不得借精修改变 subtype。PNG/JPEG/WebP 继续是有限格式选项。
- 工具动作与保存设置必须是显式上下两行,保存栅格把主按钮列固定为 `max-content` 且禁止换行;容器宽度不足时保存按钮独占整行。普通用户工作区状态只显示项目名称,不直接展示本机绝对项目路径;显式目录选择、权限确认或开发诊断不受该展示规则替代。
## 16. 2026-08-10 现有图片非破坏性编辑阶段覆盖条款
本节是当前产品入口的临时覆盖条款;与第 1、2、4、13 节中要求同时开放 create/refine 入口的文字冲突时,以本节为准。底层 create 草稿、序列化和恢复兼容继续保留,不作为当前普通用户入口。
- 资源总览的“新增资源”保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。
- 当前唯一正式入口是图片资源聚焦态的“编辑资源”。打开后必须使用 `intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。
- 普通客户确认编辑后固定调用 `POST /api/editor/images/edits`;提示词、比例、尺寸、资源用途、登录态、泥点计费和原 operation 恢复继续复用现有生成合同。
- 编辑结果固定创建新的本地 asset、文件路径、commitId 和资源身份。源 asset 与源文件不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧图片。
- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;登录刷新后只继续同一 operation。
- 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。
- 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响 refine 编辑链路。
+1
View File
@@ -132,6 +132,7 @@ export type ImageCanvasGenerationProgressPhase =
export type ImageCanvasGenerationProgress = {
intentId: string;
generationId: string;
draftRevision?: number;
phase: ImageCanvasGenerationProgressPhase;
progress: number | null;
errorCode: string | null;