From aec9568c39e5fb2d5f881452782a3307145a7ae0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 17 Sep 2026 17:47:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E9=80=9A=E7=94=BB=E5=B8=83=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8F=82=E8=80=83=E7=B4=A0=E6=9D=90=E7=9A=84=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 referenceAssetIds 原生入参:start_local_project_asset_generation 与 generate_local_project_asset 经 prepare_local_project_asset_generation 统一收口当前项目 manifest 图片素材 id PlatformArtAssetGenerationOptions 增加 reference_asset_ids,GUI、agent 桥、media 观察与 Direct 运行时构造点显式传空 参考解析固定规范图前置在前、用户参考按给出顺序随后,按远端资源 ID 去重,普通生成总量 5 张、有规范前置 4 张用户参考,图集拒绝额外参考而非静默丢弃 参考素材复用 manifest 到本地文件、图片解码、内容 hash、当前账号 binding 上传的既有通道,历史账号遗留远端 ID 不再复用 retained 阶段与清单校验改为与提交同一套请求合同:icon-spec、ui-prototype、game-background 允许正确引用,art-spritesheet 仍只接受唯一规范引用 修复 Part::file_name 的生命周期错误与 4 处测试构造点少传参考参数 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 16 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 1 + .../src-tauri/src/agent/generation.rs | 9 +- .../src/agent/generation/canvas_generation.rs | 409 +++++++++++++++--- .../src/agent/runtime_tools/media.rs | 2 + .../src-tauri/src/asset_generation_tasks.rs | 4 + .../src-tauri/src/commands.rs | 45 +- .../src-tauri/src/project/manifest.rs | 23 +- .../src-tauri/src/tests/project.rs | 6 + 9 files changed, 432 insertions(+), 83 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 619fd4162..87e4b3174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity( .to_string(), reference_resource_ids: asset.source.reference_resource_ids.clone(), }; + // 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有 + // 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。 + let references_match_contract = + crate::agent::platform_art_runtime_references_match_request_contract( + &identity.reference_resource_ids, + expected_kind, + ); let lineage_matches = match expected_reference_source { Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), - None => identity.reference_resource_ids.is_empty(), + None => true, }; - lineage_matches.then_some(identity) + (references_match_contract && lineage_matches).then_some(identity) }) } @@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source( source: &DirectTaonierArtAssetIdentity, derived: &DirectTaonierArtAssetIdentity, ) -> bool { - let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + // 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后, + // 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。 + let Some(remote_reference_id) = derived.reference_resource_ids.first() else { return false; }; if derived.canvas_project_id == source.canvas_project_id @@ -3386,6 +3395,7 @@ async fn generate_direct_taonier_art_asset_at( slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 705da3ecb..19858c852 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2262,6 +2262,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) slice_mode, grid_x, grid_y, + reference_asset_ids: Vec::new(), }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e52bbcfea..1e1600dbe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, - platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, - PLATFORM_ART_ASSET_GENERATION_KINDS, + normalize_platform_art_reference_asset_ids, platform_art_asset_art_spec, + platform_art_asset_output_extension_matches, + platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index f545cc279..a544ba9db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -420,6 +420,16 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) slice_mode: Option, pub(crate) grid_x: Option, pub(crate) grid_y: Option, + /// 本次生成用作参考的**当前项目已登记图片素材 id**(manifest `assets[].id`)。 + /// + /// 只接受当前项目 manifest 身份:路径、远端 `resourceId` / `objectKey` 与跨项目素材都会在 + /// [`resolve_platform_art_generation_references_at`] 解析阶段被拒绝,素材内容再经本地文件、 + /// 图片解码与内容 hash 换成**当前账号**绑定下的远端资源 ID。 + /// + /// 它**刻意不进** standalone 动作指纹([`StandalonePlatformArtGenerationFingerprintMaterial`]): + /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 + /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 + pub(crate) reference_asset_ids: Vec, } impl Default for PlatformArtAssetGenerationOptions { @@ -435,10 +445,19 @@ impl Default for PlatformArtAssetGenerationOptions { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), } } } +/// 普通图片生成合并规范图与用户参考后的**总参考上限**,沿用图片生成 API 已有上限。 +pub(crate) const PLATFORM_ART_MAX_REFERENCE_IMAGES: usize = 5; +/// 有规范图前置时允许的用户参考上限:规范图本身占 1 张,总量仍不超过 +/// [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +pub(crate) const PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC: usize = 4; +/// 单个参考素材 id 的长度上限(manifest 资产 id 是稳定短标识,不是路径)。 +const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; + /// 「无源生成图片类素材」参数化通道放行的 kind 目录。 /// /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, @@ -473,6 +492,67 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option }) } +/// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 +/// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { + matches!( + asset_kind, + "ui-prototype" | "game-background" | "art-spritesheet" + ) +} + +/// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { + asset_kind != "art-spritesheet" +} + +/// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 +/// +/// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 +/// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 +pub(crate) fn normalize_platform_art_reference_asset_ids( + asset_kind: &str, + asset_ids: &[String], +) -> Result, String> { + if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { + return Err("透明美术图集只接受规范图引用,不接受用户参考素材".to_string()); + } + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + continue; + } + if asset_id.chars().count() > PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + || asset_id.chars().any(char::is_control) + || asset_id.contains('/') + || asset_id.contains('\\') + || asset_id.contains("://") + { + return Err(format!( + "参考素材只接受当前项目已登记素材 ID,不接受路径或远端资源 ID:{asset_id}" + )); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + } + } + if platform_art_asset_kind_requires_canonical_spec_reference(asset_kind) { + if normalized.len() > PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC { + return Err(format!( + "有规范图前置的生成最多 {} 张用户参考素材", + PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC + )); + } + } else if normalized.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "普通图片生成最多 {} 张参考素材", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(normalized) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -1602,6 +1682,38 @@ struct CanonicalArtSpecUploadTicket { form_fields: BTreeMap, } +/// 本次生成请求的全部参考资源身份。 +struct PlatformArtGenerationReferences { + /// 规范图前置引用:需要规范图的生成类型必定存在,其余类型为 `None`。 + canonical: Option, + /// 去重后的完整引用顺序:规范图在前,用户参考随后。它就是请求里的 `referenceImageSrcs`。 + ordered: Vec, +} + +/// 参考素材上传到平台时使用的文件名:由 manifest 素材的本地路径基名派生,只保留 ASCII 安全字符。 +/// +/// 同一素材路径不变时文件名稳定,平台对象键不会随重试漂移;无法派生时退回 +/// `reference.<媒体扩展名>`,扩展名由媒体类型决定,保持与 `contentType` 一致。 +fn platform_art_reference_upload_file_name(source: &GameCreationAppAssetManifestEntry) -> String { + let extension = infer_file_extension(Some(&source.local_path), &source.media_type); + let stem = Path::new(&source.local_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| { + stem.chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .collect::() + }) + .unwrap_or_default(); + if stem.is_empty() { + return format!("reference.{extension}"); + } + format!("{stem}.{extension}") +} + +/// 规范图前置的生成类型解析当前账号的规范图引用。 async fn canonical_art_spec_reference_at( root: &Path, client: &reqwest::Client, @@ -1622,17 +1734,78 @@ async fn canonical_art_spec_reference_at( "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() })?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest.project_id, + expected_canvas_project_id, + source, + ) + .await +} + +/// 用户参考素材(当前项目 manifest `assets[].id`)解析当前账号的远端资源 ID。 +/// +/// 只接受**当前项目**清单里的图片素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 +async fn manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + asset_id: &str, +) -> Result { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))?; + if !source.media_type.starts_with("image/") { + return Err(format!( + "参考素材必须是图片,不能引用 {}:{}", + source.media_type, asset_id + )); + } + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest.project_id, + expected_canvas_project_id, + source, + ) + .await +} + +/// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 +/// +/// 复用既有 manifest → 安全文件路径 → 图片解码 → 内容 hash → 当前账号 binding/上传 → +/// 远端 resource ID 流程:binding 存在时直接复用,缺失时从本地正式文件重新上传并登记, +/// 绝不用当前 token 探测或发送历史账号的 project/resource ID。 +async fn upload_manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + manifest_project_id: &str, + expected_canvas_project_id: &str, + source: &GameCreationAppAssetManifestEntry, +) -> Result { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` 门禁, + // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 + enforce_project_permission_policy(root, "asset.upload")?; + let file_name = platform_art_reference_upload_file_name(source); let source_path = resolve_local_project_path(root, &source.local_path)?; if !source_path.is_file() { - return Err( - "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" - .to_string(), - ); + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); } - let bytes = - fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -1642,19 +1815,19 @@ async fn canonical_art_spec_reference_at( )?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } let principal_key = - external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + external_editor_project_binding_key_sha256(manifest_project_id, &principal)?; let resource_binding_key = external_editor_resource_binding_key_sha256( - &manifest.project_id, + manifest_project_id, &principal_key, expected_canvas_project_id, &source_identity, @@ -1665,14 +1838,14 @@ async fn canonical_art_spec_reference_at( access.validate_frozen_session()?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 @@ -1695,57 +1868,57 @@ async fn canonical_art_spec_reference_at( "pathSegments": [ "editor", "account-scoped-bindings", - manifest.project_id.as_str(), + manifest_project_id, source_identity.source_sha256.as_str() ], - "fileName": "art-spec.png", + "fileName": file_name.as_str(), "contentType": source.media_type, "access": "private", "maxSizeBytes": bytes.len(), "successActionStatus": 204, })), - "创建当前账号规范图上传凭证", + "创建当前账号参考图上传凭证", ) .await?; access.validate_frozen_session()?; let upload = external_editor_response_data(&ticket_payload) .get("upload") .or_else(|| ticket_payload.pointer("/data/upload")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + .ok_or_else(|| "当前账号参考图上传凭证缺少 upload".to_string())?; let ticket = CanonicalArtSpecUploadTicket { host: json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 host".to_string())?, bucket: json_string_field(upload, "bucket") - .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 bucket".to_string())?, object_key: json_string_field(upload, "objectKey") - .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 objectKey".to_string())?, success_action_status: upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证 successActionStatus 无效".to_string())?, form_fields: upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .ok_or_else(|| "当前账号参考图上传凭证缺少 formFields".to_string())? .iter() .map(|(key, value)| { value .as_str() .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + .ok_or_else(|| "当前账号参考图上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?, }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1754,18 +1927,20 @@ async fn canonical_art_spec_reference_at( form.text(key.clone(), value.clone()) }); let part = Part::bytes(bytes.clone()) - .file_name("art-spec.png") + // `Part::file_name` 只接受 `'static` 名字:这里必须交出所有权,借用会让临时串在 + // 请求发出前就结束生命周期。 + .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号规范图失败".to_string())?; + .map_err(|_| "上传当前账号参考图失败".to_string())?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( - "上传当前账号规范图失败:HTTP {}", + "上传当前账号参考图失败:HTTP {}", upload_response.status().as_u16() )); } @@ -1787,19 +1962,19 @@ async fn canonical_art_spec_reference_at( "assetKind": source.kind, "accessPolicy": "private", })), - "确认当前账号规范图上传", + "确认当前账号参考图上传", ) .await?; access.validate_frozen_session()?; let asset_object = external_editor_response_data(&confirm_payload) .get("assetObject") .or_else(|| confirm_payload.pointer("/data/assetObject")) - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObject".to_string())?; if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + return Err("当前账号参考图确认响应 objectKey 不一致".to_string()); } let asset_object_id = json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObjectId".to_string())?; let resource_payload = external_editor_json_request( client .post(format!( @@ -1828,7 +2003,7 @@ async fn canonical_art_spec_reference_at( "localAssetId": source.id, }, })), - "登记当前账号规范图项目资源", + "登记当前账号参考图项目资源", ) .await?; let post_response_session = access.validate_frozen_session(); @@ -1837,9 +2012,9 @@ async fn canonical_art_spec_reference_at( resource_data.get("resource").unwrap_or(resource_data), "resourceId", ) - .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + .ok_or_else(|| "当前账号参考图项目资源响应缺少 resourceId".to_string())?; let binding = new_external_editor_resource_binding( - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, @@ -1855,6 +2030,61 @@ async fn canonical_art_spec_reference_at( Ok(remote_resource_id) } +/// 解析本次生成请求的全部参考资源身份。 +/// +/// 顺序与上限是请求合同的一部分: +/// +/// - 规范图前置的生成类型必须先解析出当前账号的规范图引用; +/// - 用户参考来自 `options.reference_asset_ids`(当前项目 manifest `assets[].id`),按给出顺序 +/// 逐个换成当前账号的远端资源 ID,并按远端 ID 去重; +/// - 图集类型只接受单规范引用,用户参考在这里被**拒绝**而不是静默丢弃; +/// - 合并后总数不超过 [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +async fn resolve_platform_art_generation_references_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( + &options.asset_kind, + &options.reference_asset_ids, + )?; + let canonical = + if platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind) { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; + let mut ordered = Vec::new(); + if let Some(reference) = canonical.as_ref() { + ordered.push(reference.clone()); + } + for asset_id in &user_reference_asset_ids { + let reference = manifest_asset_remote_reference_at( + root, + client, + access, + expected_canvas_project_id, + asset_id, + ) + .await?; + if !ordered.iter().any(|existing| existing == &reference) { + ordered.push(reference); + } + } + if ordered.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "图片生成参考素材最多 {} 张(含规范图)", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(PlatformArtGenerationReferences { canonical, ordered }) +} + fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // External Editor validates each description independently (currently at // 200 Unicode characters). Keep the gameplay context short enough that a @@ -2370,6 +2600,44 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ .await } +/// 保留账本里的参考集合是否符合该生成类型的**请求合同**。 +/// +/// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 +/// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: +/// +/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 +/// +/// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 +/// [`resolve_platform_art_generation_references_at`],这里只回答「已有账本/清单里的这份参考集合, +/// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 +pub(crate) fn platform_art_runtime_references_match_request_contract( + reference_resource_ids: &[String], + expected_asset_kind: &str, +) -> bool { + if reference_resource_ids + .iter() + .any(|reference| reference.trim().is_empty()) + { + return false; + } + // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 + if expected_asset_kind != "game-background" + && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + { + return false; + } + if !platform_art_asset_kind_accepts_user_reference_assets(expected_asset_kind) { + return reference_resource_ids.len() == 1; + } + if platform_art_asset_kind_requires_canonical_spec_reference(expected_asset_kind) { + return (1..=PLATFORM_ART_MAX_REFERENCE_IMAGES).contains(&reference_resource_ids.len()); + } + reference_resource_ids.len() <= PLATFORM_ART_MAX_REFERENCE_IMAGES +} + pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, @@ -2390,24 +2658,35 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { + // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: + // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 "icon-spec" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.is_empty() + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "icon-spec", + ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } "game-background" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "game-background", + ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } "art-spritesheet" => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "art-spritesheet", + ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") } @@ -2647,23 +2926,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); } - if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - let current_reference = canonical_art_spec_reference_at( + { + // 恢复的判据是「本次请求解析出的当前账号引用」与账本快照逐一相符:规范图身份漂移 + // 与用户参考变化都必须被识别,不能把上一次请求的参考当成本次请求的参考恢复。 + let current_references = resolve_platform_art_generation_references_at( root, &client, &binding_access, &snapshot.canvas_project_id, + options, ) .await .map_err(|error| { format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化生成请求的当前规范图与参考素材身份:{error}" ) })?; - if snapshot.reference_resource_ids != [current_reference] { + if snapshot.reference_resource_ids != current_references.ordered { if platform_art_generation_runtime_status(&state) == "accepted" { if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) @@ -2695,7 +2974,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } } return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份或用户参考素材与已持久化请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } @@ -2777,22 +3056,17 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - Some( - canonical_art_spec_reference_at( - root, - &client, - &binding_access, - &canvas_context.project_id, - ) - .await?, - ) - } else { - None - }; + // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, + // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 + let references = resolve_platform_art_generation_references_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + options, + ) + .await?; + let canonical_reference = references.canonical.clone(); let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -2836,7 +3110,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "generationInputs": { "artSpec": platform_art_asset_art_spec(options), }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "referenceImageSrcs": references.ordered.clone(), "canvasCompletion": { "title": options.asset_label, "placeholder": external_canvas_placeholder(&options.aspect_ratio), @@ -3015,7 +3289,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at endpoint.to_string(), generation_kind.to_string(), is_canonical_art_spritesheet, - canonical_reference.into_iter().collect::>(), + references.ordered.clone(), generation_prompt.clone(), ) }; @@ -8326,6 +8600,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -10373,6 +10648,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11277,6 +11553,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11741,6 +12018,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12350,6 +12628,7 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 6f7aa3441..34c4b3e21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -577,6 +577,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), grid_x, grid_y, + reference_asset_ids: Vec::new(), }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -628,6 +629,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), grid_x, grid_y, + reference_asset_ids: requested_options.reference_asset_ids, } }; options.replace_existing = replace_existing; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 91a9eae42..6807a6e28 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -390,6 +390,9 @@ pub(crate) async fn start_local_project_asset_generation( image_size: Option, asset_name: Option, output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -400,6 +403,7 @@ pub(crate) async fn start_local_project_asset_generation( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index f5724810b..832517ff6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4610,6 +4610,7 @@ pub(crate) fn prepare_local_project_asset_generation( image_size: Option<&str>, asset_name: Option<&str>, output_path: Option<&str>, + reference_asset_ids: &[String], ) -> Result { let project_path = project_path.trim(); if project_path.is_empty() { @@ -4617,6 +4618,10 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, + // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 + let reference_asset_ids = + normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), prompt: local_project_asset_prompt(prompt)?, @@ -4650,6 +4655,7 @@ pub(crate) fn prepare_local_project_asset_generation( slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids, }, }) } @@ -4671,6 +4677,7 @@ pub(crate) async fn generate_local_project_asset( image_size: Option, asset_name: Option, output_path: Option, + reference_asset_ids: Option>, ) -> Result { let request = prepare_local_project_asset_generation( &project_path, @@ -4680,6 +4687,7 @@ pub(crate) async fn generate_local_project_asset( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -4698,7 +4706,16 @@ mod local_project_asset_generation_tests { use super::*; fn prepare(kind: &str, prompt: &str) -> Result { - prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None) + prepare_local_project_asset_generation( + "/tmp/project", + kind, + prompt, + None, + None, + None, + None, + &[], + ) } #[test] @@ -4738,6 +4755,7 @@ mod local_project_asset_generation_tests { Some("2K"), Some(" 主角图集 "), Some(" assets/hero.png "), + &[], ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -4765,8 +4783,17 @@ mod local_project_asset_generation_tests { #[test] fn invalid_toolbar_arguments_are_rejected_before_any_generation() { assert_eq!( - prepare_local_project_asset_generation("", "image", "要求", None, None, None, None) - .expect_err("empty project path"), + prepare_local_project_asset_generation( + "", + "image", + "要求", + None, + None, + None, + None, + &[] + ) + .expect_err("empty project path"), "项目路径不能为空" ); assert_eq!( @@ -4793,7 +4820,8 @@ mod local_project_asset_generation_tests { Some("4:3"), None, None, - None + None, + &[] ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -4806,7 +4834,8 @@ mod local_project_asset_generation_tests { None, Some("4K"), None, - None + None, + &[] ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -4819,7 +4848,8 @@ mod local_project_asset_generation_tests { None, None, Some("坏\u{7}名字"), - None + None, + &[] ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -4832,7 +4862,8 @@ mod local_project_asset_generation_tests { None, None, None, - Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), + &[] ) .expect_err("oversized output path"), "输出路径超出安全边界" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index a3fe7968f..d5c37f54f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset( } if task_id == "art-director" { - if !asset.source.reference_resource_ids.is_empty() { - return Err("统一视觉规范图不得声明派生资源引用".to_string()); + // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 + // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 + // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / + // resourceId / PNG 解码等身份判据全部保持不变。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { + return Err(format!( + "统一视觉规范图的参考集合不符合请求合同:{expected_path}" + )); } return Ok(()); } @@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { + // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: + // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); - }; + } + let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); let original_provenance_matches = canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; let rebound_local_source_matches = if original_provenance_matches { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 2270ca448..fb0915dce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1079,6 +1079,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }, ) .await; @@ -2656,6 +2657,7 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("2K".to_string()), Some("工具栏图片".to_string()), None, + None, ) .await .expect("toolbar asset generation"); @@ -2722,6 +2724,7 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s None, Some("视觉规范图".to_string()), None, + None, ) .await .expect("toolbar spec generation"); @@ -2788,6 +2791,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -2811,6 +2815,7 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("1K".to_string()), Some("游戏首版图集".to_string()), None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -5499,6 +5504,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",