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 3dbc2af88..2bb7b5e89 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 @@ -2063,16 +2063,14 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result { .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("image"); - if !matches!( - kind, - "image" | "character" | "icon-spec" | "ui-prototype" | "publication-material" - ) { - return Err( - "工具参数 kind 只允许 image、character、icon-spec、ui-prototype 或 publication-material" - .to_string(), - ); - } - Ok(kind.to_string()) + normalize_platform_art_asset_generation_kind(kind) + .map(str::to_string) + .ok_or_else(|| { + format!( + "工具参数 kind 只允许 {}", + PLATFORM_ART_ASSET_GENERATION_KINDS.join("、") + ) + }) } async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index a456b7596..e798c7149 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -183,7 +183,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "agc_generate_image", - "description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图或游戏 UI 设计图都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。", + "description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。", "inputSchema": { "type": "object", "properties": { @@ -191,13 +191,13 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "minLength": 1, "maxLength": DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS, - "description": "完整图片描述;普通图片、角色、规范图或 UI 设计图均可" + "description": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可" }, "kind": { "type": "string", - "enum": ["image", "character", "icon-spec", "ui-prototype", "publication-material"], + "enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "default": "image", - "description": "image=普通新图,character=角色图,icon-spec=视觉规范图,ui-prototype=完整 UI 设计图,publication-material=发布宣传图" + "description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图" }, "aspectRatio": { "type": "string", @@ -966,15 +966,10 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { return mcp_tool_result(error, Vec::new(), true); } if let Some(kind) = arguments.get("kind") { - if !kind.is_string() - || ![ - "image", - "character", - "icon-spec", - "ui-prototype", - "publication-material", - ] - .contains(&kind.as_str().unwrap_or_default()) + if !kind + .as_str() + .and_then(normalize_platform_art_asset_generation_kind) + .is_some() { return mcp_tool_result( "工具参数 kind 不是受支持的图片生成类型".to_string(), @@ -1879,16 +1874,6 @@ mod tests { .iter() .find(|tool| tool["name"] == "agc_generate_image") .expect("image tool"); - assert_eq!( - image_tool["inputSchema"]["properties"]["kind"]["enum"], - json!([ - "image", - "character", - "icon-spec", - "ui-prototype", - "publication-material" - ]) - ); assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"])); assert!(image_tool["description"] .as_str() @@ -1964,6 +1949,27 @@ mod tests { assert!(!specs.to_string().contains("apiKey")); } + #[tokio::test] + async fn generate_image_rejects_kinds_outside_the_shared_catalog_before_the_bridge() { + // 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。 + let unsupported_kind = call_agc_generate_image(&json!({ + "prompt": "像素月光主角", + "kind": "game-art" + })) + .await; + assert_eq!(unsupported_kind["isError"], json!(true)); + assert!(unsupported_kind["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("kind"))); + + let oversized_prompt = call_agc_generate_image(&json!({ + "prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1), + "kind": "art-spritesheet" + })) + .await; + assert_eq!(oversized_prompt["isError"], json!(true)); + } + #[test] fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() { assert!(validate_registered_assets_arguments(&json!({ 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 a83b6dba4..e52bbcfea 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 @@ -66,10 +66,11 @@ pub(crate) use canvas_generation::{ build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at, 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, 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, + 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, }; #[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 e242dbcd3..99177a4f4 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 @@ -429,6 +429,40 @@ impl Default for PlatformArtAssetGenerationOptions { } } +/// 「无源生成图片类素材」参数化通道放行的 kind 目录。 +/// +/// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, +/// 两条调用路径不允许各写一套白名单;目录外的 kind 一律拒绝,不做兜底猜测。 +/// +/// `spec` 只是平台图片生成的 generation kind 名称,客户端真正验证过的规范图类型是 +/// `icon-spec`:`build_platform_art_asset_prompt`、`platform_art_asset_art_spec` 与 +/// `canonical_art_spec_reference_at` 都只认 `icon-spec`,登记成 `spec` 的图既拿不到规范图 +/// 提示词,也无法作为 UI 原型与透明图集的权威参考。因此 `spec` 在 +/// `normalize_platform_art_asset_generation_kind` 里统一收口到 `icon-spec`。 +pub(crate) const PLATFORM_ART_ASSET_GENERATION_KINDS: &[&str] = &[ + "image", + "character", + "spec", + "icon-spec", + "ui-prototype", + "art-spritesheet", + "publication-material", +]; + +/// 把外部传入的 kind 收口到 `PlatformArtAssetGenerationOptions::asset_kind` 的权威取值。 +/// 返回 `None` 表示该 kind 未被验证过,调用方必须拒绝。 +pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option<&'static str> { + let kind = kind.trim(); + let canonical = PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .find(|candidate| **candidate == kind)?; + Some(if *canonical == "spec" { + "icon-spec" + } else { + canonical + }) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -7826,6 +7860,69 @@ mod canvas_generation_tests { } } + #[test] + fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { + // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 + assert_eq!( + PLATFORM_ART_ASSET_GENERATION_KINDS, + &[ + "image", + "character", + "spec", + "icon-spec", + "ui-prototype", + "art-spritesheet", + "publication-material" + ] + ); + for kind in ["image", "character", "ui-prototype", "art-spritesheet"] { + assert_eq!( + normalize_platform_art_asset_generation_kind(kind), + Some(kind), + "{kind} 必须原样落到 assetKind" + ); + } + // `spec` 只是服务端 generation kind;客户端验证过的规范图类型是 icon-spec, + // 提示词与规范图引用都只认它,所以这里必须收口而不是放行原值。 + assert_eq!( + normalize_platform_art_asset_generation_kind("spec"), + Some("icon-spec") + ); + assert_eq!( + normalize_platform_art_asset_generation_kind("icon-spec"), + Some("icon-spec") + ); + assert_eq!( + normalize_platform_art_asset_generation_kind(" art-spritesheet "), + Some("art-spritesheet") + ); + // 未验证的 kind 一律拒绝,不做兜底猜测。 + for kind in ["game-art", "game-background", "UI", "asset", "scene", ""] { + assert_eq!( + normalize_platform_art_asset_generation_kind(kind), + None, + "{kind} 不在已审核目录内,必须被拒绝" + ); + } + } + + #[test] + fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { + // 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同, + // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 + let options = PlatformArtAssetGenerationOptions { + asset_kind: "art-spritesheet".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + assert!( + build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集") + ); + assert_eq!( + platform_art_asset_art_spec(&options)["assetType"], + serde_json::json!("art") + ); + } + #[test] fn external_http_error_summary_keeps_validation_details_without_sensitive_context() { let body = serde_json::json!({ 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 4301041d4..4b9a12f6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4413,6 +4413,306 @@ pub(crate) async fn generate_platform_art_asset( Ok(generated.asset) } +/// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。 +/// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护, +/// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。 +const LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS: usize = 32_000; +const LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS: usize = 120; +const LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS: usize = 512; +const LOCAL_PROJECT_ASSET_ASPECT_RATIOS: &[&str] = &["1:1", "2:3", "3:2", "9:16", "16:9"]; +const LOCAL_PROJECT_ASSET_IMAGE_SIZES: &[&str] = &["0.5K", "1K", "2K"]; +const LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME: &str = "AI 生成素材"; + +/// 已通过校验、可直接交给参数化生成通道的一次请求。 +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct LocalProjectAssetGenerationRequest { + pub(crate) root: PathBuf, + pub(crate) prompt: String, + pub(crate) options: PlatformArtAssetGenerationOptions, +} + +fn local_project_asset_prompt(prompt: &str) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("生成要求不能为空".to_string()); + } + // 工具栏是多行输入框,换行与制表符是合法正文;其余控制字符一律拒绝。 + if prompt.chars().count() > LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + || prompt + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("生成要求超出安全边界".to_string()); + } + Ok(prompt.to_string()) +} + +fn local_project_asset_single_line( + value: Option<&str>, + max_chars: usize, + label: &str, +) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.chars().count() > max_chars || value.chars().any(char::is_control) { + return Err(format!("{label}超出安全边界")); + } + Ok(Some(value.to_string())) +} + +fn local_project_asset_choice( + value: Option<&str>, + allowed: &[&str], + default: &str, + label: &str, +) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + None => Ok(default.to_string()), + Some(value) if allowed.contains(&value) => Ok(value.to_string()), + Some(value) => Err(format!("{label}不受支持:{value}")), + } +} + +/// 收口 GUI 入参并装配参数化生成通道的 options。 +/// +/// 这里不做输出路径的 assets/ 归属与防覆盖校验:那是生成通道内 +/// `prepare_platform_art_asset_output_path_for_mode` 的职责,重复一份只会产生第二个口径。 +pub(crate) fn prepare_local_project_asset_generation( + project_path: &str, + kind: &str, + prompt: &str, + aspect_ratio: Option<&str>, + image_size: Option<&str>, + asset_name: Option<&str>, + output_path: Option<&str>, +) -> Result { + let project_path = project_path.trim(); + if project_path.is_empty() { + return Err("项目路径不能为空".to_string()); + } + let asset_kind = normalize_platform_art_asset_generation_kind(kind) + .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + Ok(LocalProjectAssetGenerationRequest { + root: PathBuf::from(project_path), + prompt: local_project_asset_prompt(prompt)?, + options: PlatformArtAssetGenerationOptions { + output_path: local_project_asset_single_line( + output_path, + LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS, + "输出路径", + )?, + aspect_ratio: local_project_asset_choice( + aspect_ratio, + LOCAL_PROJECT_ASSET_ASPECT_RATIOS, + "1:1", + "图片比例", + )?, + image_size: local_project_asset_choice( + image_size, + LOCAL_PROJECT_ASSET_IMAGE_SIZES, + "1K", + "图片尺寸", + )?, + asset_kind: asset_kind.to_string(), + asset_label: local_project_asset_single_line( + asset_name, + LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS, + "素材名称", + )? + .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), + replace_existing: false, + slice_count: None, + }, + }) +} + +/// 栏目画布底部工具栏的「无源生成图片类素材」IPC。 +/// +/// 与 `generate_platform_art_asset` 一样只返回 `UploadLocalAssetResult`(id/localPath/ +/// absolutePath/manifestPath),前端据此拿到登记后的资源身份并用 manifestPath 刷新 manifest。 +/// 生成、计费、幂等账本、下载校验、manifest/revision 登记与本地预览全部转调 +/// `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +/// +/// 与 agent 侧参数化通道一致,这里同时要求 `asset.register`:生成结果必定写入 manifest。 +#[tauri::command] +pub(crate) async fn generate_local_project_asset( + project_path: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, +) -> Result { + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let generated = generate_platform_art_asset_with_options_at( + &request.root, + &request.prompt, + &[], + &request.options, + ) + .await?; + Ok(generated.asset) +} + +#[cfg(test)] +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) + } + + #[test] + fn toolbar_kinds_all_reach_the_shared_generation_channel() { + for kind in [ + "image", + "character", + "spec", + "icon-spec", + "ui-prototype", + "art-spritesheet", + ] { + let request = prepare(kind, "像素月光厨房主角").expect("toolbar kind is supported"); + assert_eq!(request.root, PathBuf::from("/tmp/project")); + assert_eq!(request.prompt, "像素月光厨房主角"); + // `spec` 只放行到权威类型;其余 kind 原样进入参数化通道。 + let expected = if kind == "spec" { "icon-spec" } else { kind }; + assert_eq!(request.options.asset_kind, expected); + assert!(!request.options.replace_existing); + assert_eq!(request.options.slice_count, None); + } + } + + #[test] + fn omitted_options_fall_back_to_the_channel_defaults() { + let request = prepare("image", "像素月光厨房主角").expect("defaults"); + assert_eq!(request.options.aspect_ratio, "1:1"); + assert_eq!(request.options.image_size, "1K"); + assert_eq!(request.options.asset_label, "AI 生成素材"); + assert_eq!(request.options.output_path, None); + + let explicit = prepare_local_project_asset_generation( + " /tmp/project ", + " art-spritesheet ", + " 像素图集 ", + Some("16:9"), + Some("2K"), + Some(" 主角图集 "), + Some(" assets/hero.png "), + ) + .expect("explicit options"); + assert_eq!(explicit.root, PathBuf::from("/tmp/project")); + assert_eq!(explicit.prompt, "像素图集"); + assert_eq!(explicit.options.asset_kind, "art-spritesheet"); + assert_eq!(explicit.options.aspect_ratio, "16:9"); + assert_eq!(explicit.options.image_size, "2K"); + assert_eq!(explicit.options.asset_label, "主角图集"); + assert_eq!( + explicit.options.output_path.as_deref(), + Some("assets/hero.png") + ); + } + + #[test] + fn toolbar_prompts_keep_multi_line_text_but_reject_other_control_characters() { + let multiline = + prepare("character", "第一行要求\n第二行要求\t制表符").expect("multi-line prompt"); + assert!(multiline.prompt.contains('\n')); + + let error = prepare("character", "带控制字符\u{7}的要求").expect_err("control character"); + assert_eq!(error, "生成要求超出安全边界"); + } + + #[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"), + "项目路径不能为空" + ); + assert_eq!( + prepare("image", " ").expect_err("empty prompt"), + "生成要求不能为空" + ); + assert_eq!( + prepare("game-art", "要求").expect_err("unverified kind"), + "素材类型不受支持:game-art" + ); + assert_eq!( + prepare( + "spec", + &"x".repeat(LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS + 1) + ) + .expect_err("oversized prompt"), + "生成要求超出安全边界" + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + Some("4:3"), + None, + None, + None + ) + .expect_err("unsupported ratio"), + "图片比例不受支持:4:3" + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + Some("4K"), + None, + None + ) + .expect_err("unsupported size"), + "图片尺寸不受支持:4K" + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + Some("坏\u{7}名字"), + None + ) + .expect_err("control character in asset name"), + "素材名称超出安全边界" + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + ) + .expect_err("oversized output path"), + "输出路径超出安全边界" + ); + } +} + #[tauri::command] pub(crate) fn open_canvas_project( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 22c2eded2..6dd170a72 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2656,6 +2656,7 @@ fn main() { generate_ui_design_code, ensure_ui_design_resource_for_prototype, generate_platform_art_asset, + generate_local_project_asset, open_canvas_project, get_game_creation_agent_capabilities, get_limited_local_commands, 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 ff13bc3be..7ede1f3c0 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 @@ -2520,6 +2520,224 @@ async fn generate_platform_art_asset_downloads_and_registers_external_image() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn generate_local_project_asset_command_registers_the_requested_toolbar_kind() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let base_url = spawn_mock_external_canvas_generation_api_server(None); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-asset-user", + "editor-toolbar-key", + &base_url, + ); + init_local_game_project_at(&root, "project-toolbar-asset", "未命名游戏原型") + .expect("project init"); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "editor-toolbar-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "透明 PNG 像素月光主角".to_string(), + Some("16:9".to_string()), + Some("2K".to_string()), + Some("工具栏图片".to_string()), + None, + ) + .await + .expect("toolbar asset generation"); + + assert!(asset.local_path.starts_with("assets/canvas-generated/")); + assert_eq!( + fs::read(&asset.absolute_path).expect("read generated toolbar asset bytes"), + valid_test_png_bytes() + ); + assert!(Path::new(&asset.manifest_path).is_file()); + + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered toolbar asset"); + // 工具栏请求的 kind 必须一路落到 manifest,而不是退回旧 IPC 的 game-art 默认值。 + assert_eq!(entry["kind"], "image"); + assert_eq!(entry["mediaType"], "image/png"); + assert_eq!(entry["localPath"], serde_json::json!(asset.local_path)); + assert_eq!(entry["source"]["kind"], "canvas"); + assert_eq!(entry["source"]["resourceId"], "resource-1"); + assert_eq!(entry["source"]["assetObjectId"], "asset-object-1"); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test] +async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let base_url = spawn_mock_external_canvas_generation_api_server(None); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-spec-user", + "editor-toolbar-spec-key", + &base_url, + ); + init_local_game_project_at(&root, "project-toolbar-spec", "未命名游戏原型") + .expect("project init"); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "editor-toolbar-spec-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "spec".to_string(), + "原创收集玩法的统一视觉规范".to_string(), + None, + None, + Some("视觉规范图".to_string()), + None, + ) + .await + .expect("toolbar spec generation"); + + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered toolbar spec asset"); + // spec 必须收口到客户端验证过的 icon-spec,否则规范图无法作为 UI 原型与透明图集的权威参考。 + assert_eq!(entry["kind"], "icon-spec"); + assert_eq!(entry["source"]["generationKind"], "spec"); + assert_eq!( + entry["source"]["generationRoute"], + "/api/external/v1/editor/images/generations" + ); + // 规范图不引用任何既有资源,manifest 因此不写 referenceResourceIds 字段。 + assert!( + entry["source"].get("referenceResourceIds").is_none(), + "规范图不应带参考图引用:{}", + entry["source"] + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test] +async fn generate_local_project_asset_command_generates_art_spritesheet_from_the_registered_spec() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let base_url = spawn_mock_external_canvas_generation_api_server(None); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-spritesheet-user", + "editor-toolbar-spritesheet-key", + &base_url, + ); + init_local_game_project_at(&root, "project-toolbar-spritesheet", "未命名游戏原型") + .expect("project init"); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "editor-toolbar-spritesheet-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + + // 透明图集必须有权威规范图:没有已登记的 icon-spec 时必须明确失败,不能静默降级成普通图。 + let missing_reference = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "art-spritesheet".to_string(), + "原创收集玩法素材图集".to_string(), + None, + None, + None, + None, + ) + .await + .expect_err("art-spritesheet requires a registered icon-spec"); + assert!( + missing_reference.contains("assets/art-spec.png"), + "{missing_reference}" + ); + + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + bind_canvas_visual_asset_fixture_to_current_editor( + &root, + "assets/art-spec.png", + "canvas-project-1", + ); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "art-spritesheet".to_string(), + "原创收集玩法素材图集".to_string(), + Some("1:1".to_string()), + Some("1K".to_string()), + Some("游戏首版图集".to_string()), + None, + ) + .await + .expect("toolbar spritesheet generation"); + + assert!(asset.local_path.starts_with("assets/canvas-generated/")); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered toolbar spritesheet asset"); + assert_eq!(entry["kind"], "art-spritesheet"); + assert_eq!( + entry["source"]["generationRoute"], + "/api/external/v1/editor/icon-spritesheets/generations" + ); + assert_eq!(entry["source"]["generationKind"], "icon-spritesheet"); + assert_eq!( + entry["source"]["referenceResourceIds"], + serde_json::json!(["resource-icon-spec"]) + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx new file mode 100644 index 000000000..89402805e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -0,0 +1,210 @@ +import { Sparkles, X } from 'lucide-react'; +import { type FormEvent, useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; +import { + RESOURCE_CANVAS_ASSET_ASPECT_RATIOS, + RESOURCE_CANVAS_ASSET_IMAGE_SIZES, + type ResourceCanvasAssetToolAction, +} from './resourceCanvasBottomToolbarModel'; +import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; + +export type ResourceCanvasAssetGenerationSubmitInput = { + kind: ResourceCanvasAssetToolAction['assetKind']; + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; +}; + +export type ResourceCanvasAssetGenerationPanelViewProps = { + action: ResourceCanvasAssetToolAction; + onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; + onClose: () => void; +}; + +function assetGenerationErrorMessage(error: unknown) { + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error && error.message) return error.message; + return '生成素材失败'; +} + +/** + * 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / + * 生成 UI 设计图共用)。 + * + * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 + * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 + * + * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC + * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 + * 失败的选项。模型档位不由本面板提供——本地 IPC 没有 `model` 入参,渲染一个改不了请求的 + * 模型选择器就是假控件。 + */ +export function ResourceCanvasAssetGenerationPanelView({ + action, + onSubmit, + onClose, +}: ResourceCanvasAssetGenerationPanelViewProps) { + const [prompt, setPrompt] = useState(''); + const [assetName, setAssetName] = useState(action.assetName); + const [aspectRatio, setAspectRatio] = useState(action.aspectRatio); + const [imageSize, setImageSize] = useState(action.imageSize); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust + // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 + const promptMaxLength = resourceEditPromptMaxLength('image-reference'); + const canSubmit = + !submitting && prompt.trim().length > 0 && assetName.trim().length > 0; + + async function submit(event: FormEvent) { + event.preventDefault(); + const normalizedPrompt = prompt.trim(); + const normalizedAssetName = assetName.trim(); + if (!normalizedPrompt || !normalizedAssetName || submitting) { + return; + } + setSubmitting(true); + setError(null); + try { + await onSubmit({ + kind: action.assetKind, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + aspectRatio, + imageSize, + }); + } catch (submitError) { + // 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。 + setError(assetGenerationErrorMessage(submitError)); + } finally { + setSubmitting(false); + } + } + + return ( + { + if (!submitting) { + onClose(); + } + }} + panelClassName="game-approval-dialog game-resource-generation-dialog" + > +
+
+

{action.label}

+
+ +
+
+ + + {action.adjustableDimensions ? ( +
+ ({ + id: ratio, + label: ratio, + ariaLabel: `${action.label}比例 ${ratio}`, + }))} + activeId={aspectRatio} + ariaLabel={`${action.label}比例`} + columns="threeToSix" + gap="sm" + size="compact" + disabled={submitting} + onChange={setAspectRatio} + /> + ({ + id: size, + label: size, + ariaLabel: `${action.label}尺寸 ${size}`, + }))} + activeId={imageSize} + ariaLabel={`${action.label}尺寸`} + columns="three" + gap="sm" + size="compact" + disabled={submitting} + onChange={setImageSize} + /> +
+ ) : ( + + {resolveEditorImageSizeLabel({ aspectRatio, imageSize })} + + )} + + {error ? ( +

+ {error} +

+ ) : null} +
+ + 取消 + + + +
+ +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasBottomToolbarView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasBottomToolbarView.tsx new file mode 100644 index 000000000..70f976716 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasBottomToolbarView.tsx @@ -0,0 +1,259 @@ +import { + CanvasChromeButton, + CanvasToolbar, +} from '@genarrative/image-canvas-react'; +import { + AppWindow, + ClipboardList, + Image as ImageIcon, + LayoutGrid, + Music, + Upload, + UserRound, + Volume2, +} from 'lucide-react'; +import { + type ChangeEvent, + type ReactNode, + useMemo, + useRef, + useState, +} from 'react'; + +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import type { + ResourceCanvasBottomTool, + ResourceCanvasBottomToolAction, + ResourceCanvasBottomToolActionId, +} from './resourceCanvasBottomToolbarModel'; + +const RESOURCE_CANVAS_BOTTOM_TOOL_ICONS: Record< + ResourceCanvasBottomToolActionId, + ReactNode +> = { + 'generate-image':