完成客户端素材画布阶段五真实图片生成闭环
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Successful in 12m20s

通过共享生成端口接入 External Editor 图片生成与精修
建立稳定生成幂等身份和私有可恢复账本
复用正式资产事务及资源投影布局选择链路
补齐取消迟到、重复完成、重启恢复和隐私隔离测试
同步更新素材画布技术合同与项目共享记忆
This commit is contained in:
2026-08-05 18:51:49 +08:00
parent c633b1d2af
commit c776f9225b
21 changed files with 3733 additions and 107 deletions
@@ -110,11 +110,13 @@ const allowedUncalledTauriCommands = [
'commit_local_project_asset',
'create_local_project_asset_canvas_draft',
'discard_local_project_asset_canvas_draft',
'generate_local_project_asset_canvas_image',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_local_project_asset_canvas_draft',
'read_local_project_asset_canvas_media',
'recover_local_project_asset_canvas_transactions',
'recover_local_project_asset_canvas_generations',
'stage_local_project_asset_canvas_image',
'store_local_project_asset_canvas_media',
'update_local_project_asset_canvas_draft',
+17
View File
@@ -2561,6 +2561,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -4012,6 +4022,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -5768,6 +5779,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -38,7 +38,7 @@ similar = "2.7"
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls"] }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
@@ -13,14 +13,23 @@ mod run_lifecycle;
mod tests;
mod trace;
pub(crate) use canvas_generation::{
classify_external_generation_initial_response, external_canvas_placeholder,
external_editor_json_request, external_editor_response_data, external_generation_poll_after_ms,
external_generation_result_has_download_reference,
external_generation_submit_rejection_is_definitive,
platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context,
submit_external_generation_request, wait_for_external_generation_result,
ExternalCanvasGenerationContext, ExternalGenerationInitialResponse,
};
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
platform_art_generation_error_needs_reconciliation,
platform_art_generation_error_result_unknown,
request_platform_art_asset_with_runtime_options_at,
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(in crate::agent) use external_generation_state::{
game_creator_agent_runtime_external_generation_exists,
platform_art_generation_runtime_context_from_pending,
@@ -268,23 +268,25 @@ pub(crate) fn platform_art_asset_output_extension_matches(
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExternalCanvasGenerationContext {
project_id: String,
asset_folder_id: String,
canvas_name: String,
pub(crate) struct ExternalCanvasGenerationContext {
pub(crate) project_id: String,
pub(crate) asset_folder_id: String,
pub(crate) canvas_name: String,
}
fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value {
pub(crate) fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value {
payload.get("data").unwrap_or(payload)
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ExternalGenerationInitialResponse {
pub(crate) enum ExternalGenerationInitialResponse {
LegacyCompleted(serde_json::Value),
AsyncSubmission(serde_json::Value),
}
fn external_generation_result_has_download_reference(generated: &serde_json::Value) -> bool {
pub(crate) fn external_generation_result_has_download_reference(
generated: &serde_json::Value,
) -> bool {
let has_download_reference = |value: &serde_json::Value| {
json_string_field(value, "objectKey").is_some()
|| json_string_field(value, "imageSrc").is_some_and(|image_src| {
@@ -307,7 +309,7 @@ fn external_generation_result_has_download_reference(generated: &serde_json::Val
.is_some_and(has_download_reference)
}
fn external_generation_download_source(
pub(crate) fn external_generation_download_source(
generated: &serde_json::Value,
resource: &serde_json::Value,
is_canonical_art_spritesheet: bool,
@@ -371,7 +373,7 @@ fn consistent_canvas_task_id(
Ok(resolved)
}
fn classify_external_generation_initial_response(
pub(crate) fn classify_external_generation_initial_response(
status: reqwest::StatusCode,
payload: &serde_json::Value,
) -> Result<ExternalGenerationInitialResponse, String> {
@@ -405,7 +407,7 @@ fn classify_external_generation_initial_response(
}
}
pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool {
pub(crate) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool {
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
|| error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX)
|| error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)
@@ -415,7 +417,7 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
async fn external_editor_json_request(
pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
) -> Result<serde_json::Value, String> {
@@ -433,7 +435,7 @@ async fn external_editor_json_request(
.map_err(|error| format!("解析{action}响应失败:{error}"))
}
fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
external_editor_response_data(payload)
.get("pollAfterMs")
.and_then(serde_json::Value::as_u64)
@@ -442,7 +444,9 @@ fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
.min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS)
}
fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCode) -> bool {
pub(crate) fn external_generation_submit_rejection_is_definitive(
status: reqwest::StatusCode,
) -> bool {
matches!(
status,
reqwest::StatusCode::BAD_REQUEST
@@ -451,7 +455,7 @@ fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCod
)
}
async fn wait_for_external_generation_result(
pub(crate) async fn wait_for_external_generation_result(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
@@ -541,7 +545,7 @@ async fn wait_for_external_generation_result(
}
}
async fn submit_external_generation_request(
pub(crate) async fn submit_external_generation_request(
client: &reqwest::Client,
api_base_url: &str,
endpoint: &str,
@@ -630,7 +634,7 @@ async fn resume_prepared_external_generation_at(
}
}
async fn prepare_external_canvas_generation_context(
pub(crate) async fn prepare_external_canvas_generation_context(
root: &Path,
client: &reqwest::Client,
api_base_url: &str,
@@ -721,7 +725,7 @@ async fn prepare_external_canvas_generation_context(
})
}
fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value {
pub(crate) fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value {
let (width, height) = match aspect_ratio {
"16:9" => (1024, 576),
"9:16" => (576, 1024),
@@ -122,7 +122,7 @@ fn request_body_json_and_sha256(
Ok((request_body_json, request_body_sha256))
}
pub(super) fn platform_art_generation_external_configuration_fingerprint(
pub(crate) fn platform_art_generation_external_configuration_fingerprint(
api_base_url: &str,
api_key: &str,
) -> String {
@@ -535,7 +535,7 @@ fn external_asset_resolved_addresses_are_safe(
.all(|address| external_asset_ip_is_proxy_benchmark(address.ip()))
}
fn validate_external_asset_download_url(
pub(crate) fn validate_external_asset_download_url(
value: &str,
api_base_url: &str,
_came_from_stable_reference: bool,
@@ -558,7 +558,7 @@ fn validate_external_asset_download_url(
Ok(url)
}
async fn build_external_asset_download_client(
pub(crate) async fn build_external_asset_download_client(
url: &url::Url,
api_base_url: &str,
came_from_stable_reference: bool,
@@ -272,6 +272,52 @@ pub(crate) fn stage_local_project_asset_canvas_image(
stage_asset_canvas_image_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn generate_local_project_asset_canvas_image(
app: tauri::AppHandle,
input: GenerateAssetCanvasImageInput,
) -> Result<GenerateAssetCanvasImageResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn recover_local_project_asset_canvas_generations(
app: tauri::AppHandle,
input: RecoverAssetCanvasGenerationsInput,
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn read_local_project_asset_canvas_media(
input: ReadAssetCanvasMediaInput,
@@ -297,8 +343,11 @@ pub(crate) fn commit_local_project_asset(
let execution = commit_asset_canvas_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit("game-creator-local-asset-committed", payload)
.map_err(|error| error.to_string())
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
@@ -313,8 +362,11 @@ pub(crate) fn recover_local_project_asset_canvas_transactions(
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit("game-creator-local-asset-committed", payload)
.map_err(|error| error.to_string())
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
@@ -2237,6 +2237,8 @@ fn main() {
update_local_project_asset_canvas_draft,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
@@ -6,6 +6,9 @@ use std::fs::File;
use unicode_normalization::UnicodeNormalization;
use uuid::Uuid;
mod generation;
pub(crate) use generation::*;
const ASSET_CANVAS_SCHEMA_VERSION: &str = "game-creator-asset-canvas-draft.v1";
const ASSET_CANVAS_COMMIT_SCHEMA_VERSION: &str = "game-creator-local-asset-commit.v1";
const ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION: &str = "game-creator-local-asset-transaction.v1";
@@ -109,6 +112,24 @@ pub(crate) struct AssetCanvasState {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum AssetCanvasGenerationStatus {
GenerationAccepted,
GenerationRunning,
RemoteCompleted,
MediaDownloaded,
AssetDurableCommitted,
Failed,
ReconciliationRequired,
}
impl Default for AssetCanvasGenerationStatus {
fn default() -> Self {
Self::GenerationAccepted
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
enum LegacyAssetCanvasGenerationStatus {
Accepted,
Polling,
Completed,
@@ -119,22 +140,34 @@ pub(crate) enum AssetCanvasGenerationStatus {
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AssetCanvasGenerationRecord {
pub(crate) generation_id: String,
pub(crate) idempotency_key: String,
pub(crate) status: AssetCanvasGenerationStatus,
pub(crate) prompt: String,
pub(crate) operation_id: Option<String>,
#[serde(default)]
pub(crate) intent_id: String,
#[serde(default)]
pub(crate) phase: AssetCanvasGenerationStatus,
pub(crate) reference_resource_ids: Vec<String>,
pub(crate) output_media_ids: Vec<String>,
#[serde(default)]
pub(crate) output_asset_id: Option<String>,
pub(crate) error_code: Option<String>,
pub(crate) created_at: u64,
pub(crate) updated_at: u64,
#[serde(default, skip_serializing)]
idempotency_key: Option<String>,
#[serde(default, skip_serializing)]
status: Option<LegacyAssetCanvasGenerationStatus>,
#[serde(default, skip_serializing)]
prompt: Option<String>,
#[serde(default, skip_serializing)]
operation_id: Option<String>,
#[serde(default, skip_serializing)]
output_media_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AssetCanvasPendingCommit {
pub(crate) commit_id: String,
pub(crate) idempotency_key: String,
#[serde(default, skip_serializing)]
idempotency_key: Option<String>,
pub(crate) request_fingerprint: String,
}
@@ -142,7 +175,8 @@ pub(crate) struct AssetCanvasPendingCommit {
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AssetCanvasLastCommit {
pub(crate) commit_id: String,
pub(crate) idempotency_key: String,
#[serde(default, skip_serializing)]
idempotency_key: Option<String>,
pub(crate) asset_id: String,
pub(crate) event_id: String,
pub(crate) committed_project_revision: u64,
@@ -519,15 +553,30 @@ fn validate_generation_records(records: &[AssetCanvasGenerationRecord]) -> Resul
));
}
let mut ids = HashSet::with_capacity(records.len());
let mut intent_ids = HashSet::with_capacity(records.len());
for record in records {
validate_uuid_v4(&record.generation_id, "generationId")?;
validate_uuid_v4(&record.idempotency_key, "generation idempotencyKey")?;
validate_uuid_v4(&record.intent_id, "intentId")?;
if !ids.insert(record.generation_id.as_str())
|| record.prompt.chars().count() > 32_000
|| !intent_ids.insert(record.intent_id.as_str())
|| record.reference_resource_ids.len() > ASSET_CANVAS_MAX_REFERENCES
|| record
.output_asset_id
.as_deref()
.is_some_and(|value| validate_plain_component(value, "outputAssetId", 512).is_err())
{
return Err("素材画布生成记录无效".to_string());
}
if normalize_asset_canvas_references(&record.reference_resource_ids)?
!= record.reference_resource_ids
{
return Err("素材画布生成引用必须是规范化且去重后的稳定身份".to_string());
}
validate_safe_revision(record.created_at, "generation.createdAt")?;
validate_safe_revision(record.updated_at, "generation.updatedAt")?;
if record.updated_at < record.created_at {
return Err("素材画布生成记录时间顺序无效".to_string());
}
}
Ok(())
}
@@ -611,8 +660,15 @@ fn read_asset_canvas_draft_candidate(
else {
return Ok(None);
};
let draft = serde_json::from_slice::<AssetCanvasDraft>(&bytes)
let mut draft = serde_json::from_slice::<AssetCanvasDraft>(&bytes)
.map_err(|error| format!("解析 {label} 失败:{error}"))?;
normalize_legacy_generation_records(&mut draft.generations);
if let Some(pending) = draft.pending_commit.as_mut() {
pending.idempotency_key = None;
}
if let Some(last) = draft.last_commit.as_mut() {
last.idempotency_key = None;
}
validate_asset_canvas_draft(&draft, project_id, draft_id)?;
let manifest = current_asset_canvas_manifest(root)?;
if manifest.project_id != project_id {
@@ -622,6 +678,34 @@ fn read_asset_canvas_draft_candidate(
Ok(Some((draft, bytes)))
}
fn normalize_legacy_generation_records(records: &mut [AssetCanvasGenerationRecord]) {
for record in records {
if record.intent_id.is_empty() {
record.intent_id = record.generation_id.clone();
}
if let Some(status) = record.status.take() {
record.phase = match status {
LegacyAssetCanvasGenerationStatus::Accepted => {
AssetCanvasGenerationStatus::GenerationAccepted
}
LegacyAssetCanvasGenerationStatus::Polling => {
AssetCanvasGenerationStatus::GenerationRunning
}
LegacyAssetCanvasGenerationStatus::Completed => {
AssetCanvasGenerationStatus::AssetDurableCommitted
}
LegacyAssetCanvasGenerationStatus::Failed => AssetCanvasGenerationStatus::Failed,
};
}
if record.output_asset_id.is_none() {
record.output_asset_id = record.output_media_ids.first().cloned();
}
record.reference_resource_ids =
normalize_asset_canvas_references(&record.reference_resource_ids)
.unwrap_or_else(|_| record.reference_resource_ids.clone());
}
}
fn validate_asset_canvas_draft_media(
root: &Path,
manifest: &GameCreationAppManifest,
@@ -1317,6 +1401,14 @@ pub(crate) fn store_asset_canvas_media_at(
pub(crate) fn stage_asset_canvas_image_at(
root: &Path,
input: &StageAssetCanvasImageInput,
) -> Result<StageAssetCanvasImageResult, String> {
stage_asset_canvas_image_with_token_at(root, input, None)
}
fn stage_asset_canvas_image_with_token_at(
root: &Path,
input: &StageAssetCanvasImageInput,
stable_token: Option<&str>,
) -> Result<StageAssetCanvasImageResult, String> {
validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?;
let (media_type, width, height) = validate_image_bytes(&input.media_type, &input.bytes)?;
@@ -1340,7 +1432,42 @@ pub(crate) fn stage_asset_canvas_image_at(
draft: Some(draft),
});
}
let token = new_asset_canvas_token()?;
let token = match stable_token {
Some(token) => {
validate_uuid_v4(token, "stagedImageToken")?;
token.to_string()
}
None => new_asset_canvas_token()?,
};
if stable_token.is_some() {
match read_staged_image_locked(root, &token) {
Ok((metadata, existing_bytes)) => {
if metadata.project_id != manifest.project_id
|| metadata.draft_id != input.draft_id
|| metadata.draft_revision != draft.revision
|| metadata.media_type != media_type
|| existing_bytes != input.bytes
{
return Err("稳定 staging token 已绑定到不同图片".to_string());
}
return Ok(StageAssetCanvasImageResult {
status: "staged".to_string(),
staged_image_token: Some(token),
draft_id: input.draft_id.clone(),
draft_revision: draft.revision,
media_type: Some(metadata.media_type),
sha256: Some(metadata.sha256),
byte_length: Some(metadata.byte_length),
pixel_width: Some(metadata.pixel_width),
pixel_height: Some(metadata.pixel_height),
expires_at: Some(metadata.expires_at),
draft: None,
});
}
Err(error) if !error.contains("不存在") => return Err(error),
Err(_) => {}
}
}
let extension = media_extension(&media_type)?;
let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}");
install_new_asset_canvas_file(
@@ -1620,6 +1747,23 @@ pub(crate) struct AssetCanvasCommittedEvent {
pub(crate) occurred_at: u64,
}
pub(crate) fn asset_canvas_committed_public_event(
event: &AssetCanvasCommittedEvent,
) -> serde_json::Value {
serde_json::json!({
"schemaVersion": event.schema_version,
"eventId": event.event_id,
"projectPath": event.project_path,
"projectId": event.project_id,
"committedProjectRevision": event.committed_project_revision,
"draftId": event.draft_id,
"commitId": event.commit_id,
"asset": event.asset,
"manifest": event.manifest,
"occurredAt": event.occurred_at,
})
}
impl AssetCanvasCommittedEventBody {
fn with_project_path(&self, root: &Path) -> AssetCanvasCommittedEvent {
AssetCanvasCommittedEvent {
@@ -2507,7 +2651,7 @@ fn commit_asset_canvas_at_internal(
draft.pending_commit = None;
draft.last_commit = Some(AssetCanvasLastCommit {
commit_id: input.commit_id.clone(),
idempotency_key: input.idempotency_key.clone(),
idempotency_key: None,
asset_id: asset_id.clone(),
event_id: event_id.clone(),
committed_project_revision: target_project_revision,
@@ -2758,7 +2902,6 @@ fn finish_recovered_asset_canvas_commit_locked(
.ok_or_else(|| "恢复提交时草稿不存在".to_string())?;
let already_matches = draft.last_commit.as_ref().is_some_and(|last| {
last.commit_id == journal.commit_id
&& last.idempotency_key == journal.idempotency_key
&& last.asset_id == journal.asset_id
&& last.event_id == journal.event_id
&& last.committed_project_revision == journal.target_project_revision
@@ -2772,7 +2915,7 @@ fn finish_recovered_asset_canvas_commit_locked(
draft.pending_commit = None;
draft.last_commit = Some(AssetCanvasLastCommit {
commit_id: journal.commit_id.clone(),
idempotency_key: journal.idempotency_key.clone(),
idempotency_key: None,
asset_id: journal.asset_id.clone(),
event_id: journal.event_id.clone(),
committed_project_revision: journal.target_project_revision,
@@ -2852,7 +2995,7 @@ fn recover_asset_canvas_transaction_locked(
draft.status = AssetCanvasDraftStatus::Committed;
draft.last_commit = Some(AssetCanvasLastCommit {
commit_id: journal.commit_id.clone(),
idempotency_key: journal.idempotency_key.clone(),
idempotency_key: None,
asset_id: journal.asset_id.clone(),
event_id: journal.event_id.clone(),
committed_project_revision: journal.target_project_revision,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -76,6 +76,107 @@
color: #fff !important;
}
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport,
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport-tools {
pointer-events: none;
}
.asset-canvas-surface__dialog-backdrop {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(35, 31, 28, 0.48);
}
.asset-canvas-surface__generation-dialog {
display: grid;
width: min(560px, 100%);
max-height: min(700px, calc(100dvh - 32px));
overflow: auto;
border: 1px solid rgba(63, 54, 48, 0.16);
border-radius: 16px;
background: #fcfbf9;
box-shadow: 0 24px 80px rgba(35, 31, 28, 0.24);
}
.asset-canvas-surface__generation-dialog > header,
.asset-canvas-surface__generation-dialog footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
}
.asset-canvas-surface__generation-dialog > header {
border-bottom: 1px solid rgba(63, 54, 48, 0.1);
}
.asset-canvas-surface__generation-dialog > header button {
min-width: 34px;
padding: 0;
}
.asset-canvas-surface__generation-form,
.asset-canvas-surface__generation-confirmation {
display: grid;
gap: 14px;
padding: 16px;
}
.asset-canvas-surface__generation-form label {
display: grid;
gap: 6px;
font-size: 13px;
}
.asset-canvas-surface__generation-form textarea {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid rgba(63, 54, 48, 0.18);
border-radius: 10px;
padding: 10px;
background: #fff;
color: inherit;
font: inherit;
}
.asset-canvas-surface__generation-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.asset-canvas-surface__generation-form fieldset {
display: grid;
max-height: 180px;
gap: 8px;
overflow: auto;
border: 1px solid rgba(63, 54, 48, 0.12);
border-radius: 10px;
padding: 10px;
}
.asset-canvas-surface__generation-form fieldset label {
display: flex;
align-items: center;
}
.asset-canvas-surface__generation-form fieldset input {
min-height: auto;
}
.asset-canvas-surface__generation-dialog footer {
justify-content: flex-end;
padding: 0;
}
.asset-canvas-surface__viewport {
grid-row: 2;
min-height: 0;
@@ -158,4 +259,8 @@
width: 112px;
flex: 0 0 auto;
}
.asset-canvas-surface__generation-options {
grid-template-columns: 1fr;
}
}
@@ -1,5 +1,8 @@
import type {
ImageCanvasDraft,
ImageCanvasGenerationCommitResult,
ImageCanvasGenerationProgress,
ImageCanvasGenerationRecord,
ImageCanvasGenerationPort,
ImageCanvasHostPort,
ImageCanvasHostResult,
@@ -16,6 +19,8 @@ import type {
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
export const LOCAL_ASSET_COMMITTED_EVENT =
'game-creator-local-asset-committed' as const;
export const LOCAL_ASSET_GENERATION_PROGRESS_EVENT =
'game-creator-asset-generation-progress' as const;
type Invoke = <T>(
command: string,
@@ -53,6 +58,19 @@ type StagedImageResult = {
draft: ImageCanvasDraft | null;
};
type GenerationCommandResult = {
generation: ImageCanvasGenerationRecord;
images: [];
commit: ImageCanvasGenerationCommitResult;
};
type GenerationProgressEvent = ImageCanvasGenerationProgress & {
schemaVersion: 'game-creator-asset-generation-progress.v1';
projectId: string;
draftId: string;
occurredAt: number;
};
export type LocalAssetCommitResult =
| {
status: 'committed' | 'already-committed';
@@ -92,7 +110,7 @@ export type LocalAssetCommittedEvent = {
committedProjectRevision: number;
draftId: string;
commitId: string;
idempotencyKey: string;
idempotencyKey?: string;
asset: GameCreationAppAssetManifestEntry;
manifest: GameCreationAppManifest;
occurredAt: number;
@@ -179,6 +197,10 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
});
return {
generateImage: unsupported,
recoverImages: async () => ({
status: 'ok' as const,
value: { resumedGenerationIds: [] },
}),
removeBackground: unsupported,
};
}
@@ -204,7 +226,101 @@ export function createTauriImageCanvasHostAdapter(input: {
draftId: scope.draftId,
};
};
const generation = createMockImageCanvasGenerationPort();
const listenGenerationProgress = async (
scope: ImageCanvasHostScope,
generationId: string | null,
callback?: (progress: ImageCanvasGenerationProgress) => void,
) => {
if (!callback) return () => undefined;
const listenEvent = input.listenEvent ?? listen;
return await listenEvent<GenerationProgressEvent>(
LOCAL_ASSET_GENERATION_PROGRESS_EVENT,
(event) => {
const progress = event.payload;
if (
progress.projectId === scope.projectId &&
progress.draftId === scope.draftId &&
(generationId === null || progress.generationId === generationId)
) {
callback({
intentId: progress.intentId,
generationId: progress.generationId,
phase: progress.phase,
progress: progress.progress,
errorCode: progress.errorCode,
});
}
},
);
};
const generation: ImageCanvasGenerationPort = {
async generateImage(generationInput) {
let unlisten: (() => void) | undefined;
try {
unlisten = await listenGenerationProgress(
generationInput.scope,
generationInput.generationId,
generationInput.onProgress,
);
const result = await invokeInput<GenerationCommandResult>(
'generate_local_project_asset_canvas_image',
{
...baseScope(generationInput.scope),
expectedHostRevision: safeRevisionString(
generationInput.expectedHostRevision,
'expectedHostRevision',
),
expectedDraftRevision: safeRevision(
generationInput.expectedDraftRevision,
'expectedDraftRevision',
),
intentId: generationInput.intentId,
generationId: generationInput.generationId,
idempotencyKey: generationInput.idempotencyKey,
commitId: generationInput.commitId,
commitIdempotencyKey: generationInput.commitIdempotencyKey,
prompt: generationInput.prompt,
aspectRatio: generationInput.aspectRatio,
imageSize: generationInput.imageSize,
assetKind: generationInput.assetKind,
assetName: generationInput.assetName,
referenceResourceIds: generationInput.referenceResourceIds,
},
);
return { status: 'ok', value: result };
} catch (error) {
return failure(error);
} finally {
unlisten?.();
}
},
async recoverImages(recoverInput) {
let unlisten: (() => void) | undefined;
try {
unlisten = await listenGenerationProgress(
recoverInput.scope,
null,
recoverInput.onProgress,
);
const result = await invokeInput<{ resumedGenerationIds: string[] }>(
'recover_local_project_asset_canvas_generations',
baseScope(recoverInput.scope),
);
return { status: 'ok', value: result };
} catch (error) {
return failure(error);
} finally {
unlisten?.();
}
},
async removeBackground() {
return {
status: 'unsupported-capability',
capability: 'advancedBackgroundRemoval',
message: '首版只接入图片生成与精修,不扩展高级去背景',
};
},
};
const stagedByCommit = new Map<
string,
{
@@ -225,7 +341,7 @@ export function createTauriImageCanvasHostAdapter(input: {
wallet: false,
cloudAssetLibrary: false,
localProject: true,
externalEditorGeneration: false,
externalEditorGeneration: true,
advancedBackgroundRemoval: false,
},
project: {
@@ -1387,6 +1387,18 @@ export default function ProjectDevelopmentView({
setAssetCanvasNotice('新资源已保存,但被当前搜索条件隐藏');
return;
}
if (resolution === 'wait-projection') {
setAssetCanvasNotice('asset durable committed,正在等待 manifest 投影…');
return;
}
if (resolution === 'wait-layout') {
setAssetCanvasNotice('manifest projected,正在等待两种资源布局就绪…');
return;
}
if (resolution === 'wait-dom') {
setAssetCanvasNotice('layout ready,正在选择新资源…');
return;
}
if (resolution !== 'focus' || !intent.resourceId || !card) {
return;
}
@@ -23,7 +23,6 @@ import {
type RenderAssetCanvasImage,
} from '../src/features/asset-canvas/AssetCanvasSurface';
import {
createMockImageCanvasGenerationPort,
createTauriImageCanvasHostAdapter,
type LocalAssetCommittedEvent,
type TauriImageCanvasHostAdapter,
@@ -129,6 +128,14 @@ function memoryHost(input?: {
let eventListener: ((event: LocalAssetCommittedEvent) => void) | null = null;
const updates: ImageCanvasDraftCanvas[] = [];
const commits: Array<{ commitId: string; idempotencyKey: string }> = [];
const generationCalls: Array<{
intentId: string;
generationId: string;
idempotencyKey: string;
commitId: string;
commitIdempotencyKey: string;
referenceResourceIds: string[];
}> = [];
const revokedSubscriptions = vi.fn();
const manifest = manifestFixture(draft?.projectId ?? scope.projectId);
const loadDraft = vi.fn(async () => ({
@@ -152,7 +159,7 @@ function memoryHost(input?: {
wallet: false,
cloudAssetLibrary: false,
localProject: true,
externalEditorGeneration: false,
externalEditorGeneration: true,
advancedBackgroundRemoval: false,
},
project: {
@@ -214,25 +221,85 @@ function memoryHost(input?: {
};
},
},
generation: input?.generationGate
? {
async generateImage() {
await input.generationGate?.promise;
return {
status: 'unsupported-capability' as const,
capability: 'externalEditorGeneration' as const,
message: '迟到的 mock generation',
};
generation: {
async generateImage(generationInput) {
generationCalls.push({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
idempotencyKey: generationInput.idempotencyKey,
commitId: generationInput.commitId,
commitIdempotencyKey: generationInput.commitIdempotencyKey,
referenceResourceIds: generationInput.referenceResourceIds,
});
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'generation-accepted',
progress: 10,
errorCode: null,
});
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'generation-running',
progress: 35,
errorCode: null,
});
await input?.generationGate?.promise;
hostRevision += 1;
if (!draft) throw new Error('draft missing');
draft = { ...draft, revision: draft.revision + 1, status: 'committed' };
const generation = {
generationId: generationInput.generationId,
intentId: generationInput.intentId,
phase: 'asset-durable-committed' as const,
referenceResourceIds: generationInput.referenceResourceIds,
outputAssetId: 'canvas-commit',
errorCode: null,
createdAt: 1,
updatedAt: 2,
};
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'asset-durable-committed',
progress: 100,
errorCode: null,
});
return {
status: 'ok' as const,
value: {
generation,
images: [],
commit: {
resourceId: 'local-asset:canvas-commit',
assetId: 'canvas-commit',
projectId: generationInput.scope.projectId,
commitId: generationInput.commitId,
committedProjectRevision: hostRevision,
draftRevision: draft.revision,
hostRevision: String(hostRevision),
commitStatus: 'committed' as const,
manifest,
eventId: `generation-event-${generationInput.generationId}`,
},
},
async removeBackground() {
return {
status: 'unsupported-capability' as const,
capability: 'advancedBackgroundRemoval' as const,
message: '当前阶段不接真实去背景',
};
},
}
: createMockImageCanvasGenerationPort(),
};
},
async recoverImages() {
return {
status: 'ok' as const,
value: { resumedGenerationIds: [] },
};
},
async removeBackground() {
return {
status: 'unsupported-capability' as const,
capability: 'advancedBackgroundRemoval' as const,
message: '当前阶段不接真实去背景',
};
},
},
completion: {
async commitImage(commitInput) {
commits.push({
@@ -271,6 +338,7 @@ function memoryHost(input?: {
return {
host,
commits,
generationCalls,
updates,
revokedSubscriptions,
loadDraft,
@@ -518,7 +586,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(onCommitted).not.toHaveBeenCalled();
});
it('明确显示 mock generation小地图/复位和 1280×800 基础布局可用', async () => {
it('确认前零生成调用,并保持小地图/复位和 1280×800 基础布局可用', async () => {
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'layout-layer',
@@ -541,8 +609,18 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) });
renderSurface(memory.host);
await screen.findByLabelText('图层 布局图层');
fireEvent.click(screen.getByRole('button', { name: 'Mock 生成' }));
expect(await screen.findByText(/明确 mock/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expect(memory.generationCalls).toHaveLength(0);
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '一张原创游戏场景插画' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
expect(memory.generationCalls).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(memory.generationCalls).toHaveLength(0);
expect(screen.getByRole('button', { name: /复位/ })).toBeTruthy();
expect(
document.querySelector('.genarrative-image-canvas__minimap'),
@@ -558,6 +636,133 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(css).toContain('@media (max-width: 760px)');
});
it('确认按钮连点只提交一次,并冻结同一组生成与正式提交幂等身份', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationGate: gate,
});
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
await screen.findByText('canvas.editing');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '稳定幂等身份的游戏场景' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
const confirm = screen.getByRole('button', { name: '确认并生成' });
fireEvent.click(confirm);
fireEvent.click(confirm);
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
expect(screen.getByText('generation-running')).toBeTruthy();
const identity = memory.generationCalls[0]!;
const uuidV4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const stableIds = [
identity.intentId,
identity.generationId,
identity.idempotencyKey,
identity.commitId,
identity.commitIdempotencyKey,
];
stableIds.forEach((value) => expect(value).toMatch(uuidV4));
expect(new Set(stableIds).size).toBe(5);
expect(onSaveAttempt).toHaveBeenCalledWith(
expect.objectContaining({ commitId: identity.commitId }),
);
await act(async () => gate.resolve());
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
memory.emit({
schemaVersion: 'game-creator-local-asset-committed.v1',
eventId: `generation-event-${identity.generationId}`,
projectPath: '/fixture/project',
projectId: scope.projectId,
committedProjectRevision: 1,
draftId: scope.draftId,
commitId: identity.commitId,
idempotencyKey: identity.commitIdempotencyKey,
asset: manifestFixture(scope.projectId).assets[0]!,
manifest: manifestFixture(scope.projectId),
occurredAt: 1,
});
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it('停止等待后迟到完成仍可后台对账,但不会抢回当前界面焦点', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationGate: gate,
});
const { onCancel, onCommitted } = renderSurface(memory.host);
await screen.findByText('canvas.editing');
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));
fireEvent.click(screen.getByRole('button', { name: '停止等待并返回' }));
expect(onCancel).toHaveBeenCalledTimes(1);
await act(async () => gate.resolve());
await act(async () => Promise.resolve());
expect(onCommitted).not.toHaveBeenCalled();
});
it('精修生成强制保留源资源引用并传入共享 generation port', async () => {
const refineScope: ImageCanvasHostScope = {
projectId: 'project-refine',
draftId: '12121212-1212-4212-8212-121212121212',
intent: 'refine',
sourceAssetId: 'source-asset',
};
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '精修源图',
mediaRef: { kind: 'project-asset', assetId: 'source-asset' },
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 0,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
});
const memory = memoryHost({
initialDraft: draftFixture(refineScope, canvas),
});
renderSurface(memory.host, refineScope);
await screen.findByLabelText('图层 精修源图');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
const sourceReference = screen.getByRole('checkbox', {
name: /精修源图/,
});
expect((sourceReference as HTMLInputElement).checked).toBe(true);
expect((sourceReference as HTMLInputElement).disabled).toBe(true);
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));
expect(memory.generationCalls[0]?.referenceResourceIds).toEqual([
'local-asset:source-asset',
]);
});
it('相同 scope 值的父级重渲染不会重开草稿,锁定图层选择仍会持久化', async () => {
const canvas = emptyCanvas();
canvas.layers.push({
@@ -606,7 +811,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
);
});
it('项目切换后丢弃迟到的 mock generation 回调', async () => {
it('项目切换后丢弃 generation 轮询结果,不覆盖新项目状态', async () => {
const gate = deferred<void>();
const oldMemory = memoryHost({
initialDraft: draftFixture(scope),
@@ -621,7 +826,13 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const onCommitted = vi.fn();
const view = renderSurface(oldMemory.host, scope, onCommitted);
await screen.findByText('canvas.editing');
fireEvent.click(screen.getByRole('button', { name: 'Mock 生成' }));
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(oldMemory.generationCalls).toHaveLength(1));
view.rerender(
<AssetCanvasSurface
host={nextMemory.host}
@@ -634,7 +845,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
);
await screen.findByText('canvas.editing');
await act(async () => gate.resolve());
expect(screen.queryByText('迟到的 mock generation')).toBeNull();
expect(onCommitted).not.toHaveBeenCalled();
expect(nextMemory.generationCalls).toHaveLength(0);
});
it('恢复前先建立事件订阅,并投影 recovery 发布的固定事件', async () => {
@@ -6145,3 +6145,13 @@
- telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScripthidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。
- CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-05 客户端素材画布真实图片生成复用 External Editor 与正式资产事务
- 生成入口决策:客户端共享素材画布只实现首版图片生成与精修,通过共享 `ImageCanvasGenerationPort` 调用既有 External Editor v1 与 `canvas.asset_generate`,不复制网站 `useImageCanvasGenerationWorkflow`,不建立第二套生成、扣费或发布系统。
- 确认决策:编辑 prompt、尺寸、比例、素材类型与参考图只改本地 UI;最终“确认生成”之前零 External 请求。一次已确认意图固定 `intentId + generationId + External idempotencyKey + commitId + commit idempotencyKey`,未知结果、重启和重复点击只能沿原身份恢复。
- 持久化决策:prompt、精确请求快照、配置指纹、External key、operationId、上传凭证和远端稳定引用只写 `.agent/workbench/asset-canvas/generations/` 私有账本;公开草稿、manifest 与事件只投影安全阶段和本地资产身份。External taskId 不得写入本地 manifest taskId。
- 恢复决策:`accepted/running` 只 GET 原 operation,提交未知只用原请求字节和原 key 对账;远端完成后以固定 staging token 下载,再进入阶段三 journal/ledger。应用打开严格先恢复本地正式资产事务,再恢复 generation ledger,因此下载后提交中断只恢复原本地事务,不重新生成或下载。
- 显现决策:`generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected` 不可压成 completed;停止 UI 等待只失效聚焦意图,迟到完成仍可安全投影 manifest,但不能抢焦点。
- 精修身份决策:正式本地资源通过 `referenceResourceIds` 引用原资源;External `sourceImageSrc` 只使用 objectKey、项目 resourceId 或素材 assetId,不能把 assetObjectId 当成媒体引用。`sourceResourceId` 只允许真实 External resourceId,不能使用 `local-asset:*` 或 objectKey 冒充。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``packages/image-canvas-core/src/ports.ts``docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`
@@ -4204,3 +4204,12 @@
- 原因:脚本用 `Buffer.readUInt32BE(0)` 读取文件头,却只比较 `0xfeedfacf` 等正序数值;arm64 常见头字节是 `cf fa ed fe`,读取结果为 `0xcffaedfe`
- 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。
- 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。
## 远端图片 completed 不能冒充本地资源创建成功(2026-08-05)
- 现象:External operation 已返回 completed,但稳定引用缺失、下载失败、正式资产事务中断或 manifest 已提交而 UI 事件丢失时,界面仍可能提前显示“资源创建成功”,重复回调还可能再次下载、写文件或登记资源。
- 原因:把远端生成、媒体传输、本地 durability、manifest 投影、布局和选择压成一个 completed 布尔值;同时把 External idempotencyKey、operationId 或 taskId 暴露到公开草稿,导致恢复逻辑从非权威状态重建请求或误绑本地 task graph。
- 处理:使用私有 generation ledger 保存原请求、External 身份、稳定远端引用、固定 staging token 与本地 commit 身份;公开面只投影不可逆阶段。启动时先恢复阶段三事务,再恢复原 generation;重复 completed 先检查远端引用、staging 和 committed ledger,只有 `committed | already-committed` 才进入 manifest 投影。用户取消等待只推进 focus generation,不删除账本或伪装远端取消。
- 精修补充:`sourceImageSrc` 是可下载的稳定媒体引用,`sourceResourceId` 是资源身份,二者不能因为都可表现为字符串就填同一个 objectKey。本地 `local-asset:*` 只保留在本地 manifest 血缘;没有真实 External resourceId 时省略 `sourceResourceId`
- 验证:覆盖确认前零调用、同 key 连点、accepted 重启 GET-only、重复 completed、取消后迟到、下载后本地事务恢复、事件丢失、切项目/改选择、旧轮询隔离、实时布局与选择、精修血缘及敏感字段零泄漏。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs``apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`
@@ -948,3 +948,11 @@ game-project/
- 保存开始冻结 `saveAttemptId + sessionId + draftId + commitId + focusGeneration`。迟到提交始终可以更新当前项目的权威 manifest,但只有 flow、项目、保存尝试和 generation 仍匹配的最新意图可以继续定位。定位依次等待资源投影、dependency/type 两份布局 settled 且均有位置、当前筛选可见和 `data-resource-id` DOM 卡片存在;完成后按 commitId 只执行一次。搜索隐藏时保留条件并提供“清除搜索并定位”,该显式动作建立新的 focus generation。
- 锁职责复核保持阶段三实现:正式提交/恢复固定为“项目 mutation write lock -> asset-canvas draft/transaction lock -> manifest store lock”;资源布局只取得独立 `.layout.lock`,不取得项目 mutation 或 asset-canvas 锁,布局协调只发生在提交释放锁并返回/发事件之后,不形成反向锁顺序。
- 阶段四新增纯状态机与中央主视窗集成测试,覆盖响应/事件双顺序、旧 manifest、旧 graph/layout、切项目、改选择、连续保存、搜索隐藏、布局未完成、DOM 一次聚焦、refine 真实依赖以及无 reload/重开项目。最终通过项以本次任务验证记录为准。
## 2026-08-05 客户端素材创作无限画布阶段五真实图片生成
- 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用 `canvas.asset_generate` 的 External Editor 会话准备、异步提交/查询、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。
- 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。
- External endpoint、精确请求、prompt、配置指纹、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入项目事实;草稿/manifest/event/日志只投影安全阶段与本地身份,External taskId 不写 manifest `source.taskId`
- 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。
- 应用重启时 `accepted/running` 只恢复 GET`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。
@@ -882,3 +882,56 @@ cancelling
| A28 | 恢复草稿 | 主文件损坏但恢复副本可信/不可信 | 可信副本恢复到 clean history 基线;不可信进入对账,不猜测 |
下一阶段只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。
## 14. 2026-08-05 阶段五真实图片生成补丁
本节是 Tauri 真实图片生成的编码级补丁;与第 3、6、12 节的早期四态生成记录冲突时,以本节为准。网站继续复用现役生成工作流,客户端只通过共享 `ImageCanvasGenerationPort` 接入,不复制 `useImageCanvasGenerationWorkflow`,也不新建第二套服务端生成系统。
### 14.1 确认与身份
- 打开生成面板、修改 prompt / 比例 / 尺寸 / `assetKind`、选择参考资源和预览确认摘要都只能产生本地 UI 状态,确认前不得读取或创建 External Editor 项目、素材目录、上传票据或生成 operation。
- 只有用户点击独立确认面板中的“确认生成”才进入现有 `canvas.asset_generate` confirm 权限边界。项目 deny 继续优先;客户端不得调用 auto gate、模拟确认或在恢复时分配新逻辑请求。
- 一次逻辑生成在本地意图建立时固定分配规范 UUID v4 `intentId``generationId`、External `idempotencyKey`、本地 `commitId` 和本地 commit idempotency key。用户取消确认可直接丢弃这组未使用身份且保持零 External 调用;一旦点击“确认生成”,重复点击、IPC 响应丢失、进程重启和结果未知都复用原值与原始请求字节。只有远端明确 terminal failed 后,用户再次明确确认才可建立新 intent。
- External taskId 是远端生成事实,不是本地 manifest task 身份。阶段五本地正式资产的 `source.taskId` 固定为 `null`,不能把 External taskId 填入并参与本地 producer/task graph。
### 14.2 私有账本与公开投影
私有账本固定放在:
```text
.agent/workbench/asset-canvas/generations/<generationId>.json
```
账本由每个 `projectId + generationId` 的进程内 single-flight、安全相对路径和原子 sidecar 写入保护,包含精确 External endpoint / 请求 JSON / SHA-256、prompt 私有正文、配置指纹、External idempotency key、operationId、稳定 `objectKey/resourceId/assetObjectId`、本地 staging 与 commit 恢复身份。阶段三正式资产事务继续由既有持久锁保护。API Key、Authorization、Provider URL、签名 URL 和绝对路径不得写入账本;API Key 只参与不可逆配置指纹。
草稿、manifest、Tauri 事件、Agent DB、Runtime observation、trace、普通错误和日志只允许出现 `intentId/generationId`、安全阶段、引用资源 ID、本地 asset/commit/event 身份和稳定错误码。草稿 generation record 不再保存 prompt、External idempotency key 或 operationId;阶段五读取旧空记录兼容,但下一次成功写入必须移除这些私有字段。
### 14.3 不可压缩状态机
生成与显现必须按下列阶段推进,不能用单个 `completed` 布尔值代替:
```text
confirmation-required
→ generation-accepted
→ generation-running
→ remote-completed
→ media-downloaded
→ asset-durable-committed
→ manifest-projected
→ layout-ready
→ selected
```
- `generation-accepted` 只表示 External `202 + operationId` 已原子落账;`queued/running` 继续属于远端生成期。
- `remote-completed` 必须已经取得自洽稳定引用;缺 `objectKey/resourceId/assetObjectId` 中可支持下载与身份核验的必要组合时进入 `reconciliation-required`,不得显示成功。
- `media-downloaded` 表示媒体通过 `/assets/read-url` 换签、SSRF/重定向门禁、大小、签名、完整解码和尺寸检查,并已写入阶段三受控 staging;重复 completed 不再下载或重写。
- `asset-durable-committed` 只来自阶段三 journal / ledger 的 `committed | already-committed`。下载成功但提交失败时保留 generation ledger、staging token 和原 commit 身份,恢复只继续本地事务,不重新生成或下载。
- `manifest-projected / layout-ready / selected` 继续复用阶段四单向链路。只有当前项目、flow、session、draft、intent 和 focus generation 全匹配时才允许选择;manifest 已提交但事件丢失时以 command/recovery 的权威完整快照继续投影。
### 14.4 首版请求范围与恢复
- 普通生成使用 `POST /api/external/v1/editor/images/generations`,支持 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9``0.5K | 1K | 2K`、合法 `assetKind` 和最多 9 个稳定参考资源。
- refine 使用 `POST /api/external/v1/editor/images/edits``sourceImageSrc` 必须来自源资产的 objectKey、External 项目 resourceId 或素材 assetId`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走现有 direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有 External resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。
- `accepted/running` 重启只查询原 operation`prepared` 只以账本中的原 endpoint、原始字节和原键恢复同一 POST。轮询超时、临时鉴权/网关错误或结果未知保持可恢复,不自动转 failed 或换键扣费。
- 用户“停止等待”只撤销当前 session 的聚焦意图,不伪装远端取消。后台 command 可以继续安全 reconcile;应用退出后由项目恢复扫描继续原 operation。迟到完成可以更新原项目权威缓存和 manifest,但不能切回旧画布、清搜索或抢焦点。
- 配置缺失、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。
+54 -7
View File
@@ -90,12 +90,12 @@ export type ImageCanvasDraft = {
generations: ImageCanvasGenerationRecord[];
pendingCommit: {
commitId: string;
idempotencyKey: string;
idempotencyKey?: string;
requestFingerprint: string;
} | null;
lastCommit: {
commitId: string;
idempotencyKey: string;
idempotencyKey?: string;
assetId: string;
eventId: string;
committedProjectRevision: number;
@@ -106,17 +106,50 @@ export type ImageCanvasDraft = {
export type ImageCanvasGenerationRecord = {
generationId: string;
idempotencyKey: string;
status: 'accepted' | 'polling' | 'completed' | 'failed';
prompt: string;
operationId: string | null;
intentId: string;
phase:
| 'generation-accepted'
| 'generation-running'
| 'remote-completed'
| 'media-downloaded'
| 'asset-durable-committed'
| 'failed'
| 'reconciliation-required';
referenceResourceIds: string[];
outputMediaIds: string[];
outputAssetId: string | null;
errorCode: string | null;
createdAt: number;
updatedAt: number;
};
export type ImageCanvasGenerationProgressPhase =
| 'confirmation-required'
| ImageCanvasGenerationRecord['phase']
| 'manifest-projected'
| 'layout-ready'
| 'selected';
export type ImageCanvasGenerationProgress = {
intentId: string;
generationId: string;
phase: ImageCanvasGenerationProgressPhase;
progress: number | null;
errorCode: string | null;
};
export type ImageCanvasGenerationCommitResult = {
resourceId: string;
assetId: string;
projectId: string;
commitId: string;
committedProjectRevision: number;
draftRevision: number;
hostRevision: string;
commitStatus: 'committed' | 'already-committed';
manifest: unknown;
eventId: string;
};
export type ImageCanvasHostInputImage = {
name: string;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
@@ -172,17 +205,31 @@ export interface ImageCanvasAssetPort {
export interface ImageCanvasGenerationPort {
generateImage(input: {
scope: ImageCanvasHostScope;
expectedHostRevision: string;
expectedDraftRevision: number;
intentId: string;
generationId: string;
idempotencyKey: string;
commitId: string;
commitIdempotencyKey: string;
prompt: string;
aspectRatio: '1:1' | '2:3' | '3:2' | '9:16' | '16:9';
imageSize: '0.5K' | '1K' | '2K';
assetKind: string;
assetName: string;
referenceResourceIds: string[];
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<
ImageCanvasHostResult<{
generation: ImageCanvasGenerationRecord;
images: ImageCanvasHostImage[];
commit: ImageCanvasGenerationCommitResult;
}>
>;
recoverImages(input: {
scope: ImageCanvasHostScope;
onProgress?: (progress: ImageCanvasGenerationProgress) => void;
}): Promise<ImageCanvasHostResult<{ resumedGenerationIds: string[] }>>;
removeBackground(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;