diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index b452cdca0..d259be149 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -13,7 +13,7 @@ "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求", "taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate", "agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。", - "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可", + "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", "agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图", "agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称", "agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json index d0a2ce412..bb96a7205 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/media.json @@ -23,8 +23,6 @@ "icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", "ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}", "scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", - "default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。", - "spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}", "ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。", "default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。", "custom_inspection_focus": "检查重点:{question}", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json index 1d93990b3..75bf4c9f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/native-tools.json @@ -36,7 +36,7 @@ "preview.start.description": "启动当前项目的 loopback HTTP 预览。", "preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect.description": "让视觉模型检查一至两张项目内图片。", - "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", + "canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。", "ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。", "cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。", "unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。", 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 74046bc2e..5b09bc5d6 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 @@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{ 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, + validate_platform_art_icon_prompt, 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 49df719b9..e79f40ac9 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 @@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options( let snapshot = platform_art_generation_runtime_request_snapshot(&state)?; // 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。 if snapshot.generation_kind == generation_kind - && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options) + && snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options) && options.asset_kind == kind && options.aspect_ratio == ratio && options.image_size == size @@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at( 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 - // long creation request cannot reject the atlas before it is queued. - const MAX_DESCRIPTION_CHARS: usize = 200; - const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - let category = - "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] -} - -fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { - let normalized = value.split_whitespace().collect::>().join(" "); - let actual_chars = normalized.chars().count(); - if actual_chars <= max_chars { - return normalized; +pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> { + // 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。 + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("图标素材描述不能为空".to_string()); } - if max_chars <= 3 { - return ".".repeat(max_chars); + if prompt.chars().count() > 200 { + return Err("图标素材描述不能超过 200 个字符".to_string()); } - let mut output = normalized - .chars() - .take(max_chars.saturating_sub(3)) - .collect::(); - output.push_str("..."); - output + Ok(()) } fn decode_platform_art_image_with_limits( @@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration { pub(in crate::agent) fn admit_platform_art_generation_at( root: &Path, prompt: &str, - briefs: &[AgentGroupBrief], + _briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard( @@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at( options: &PlatformArtAssetGenerationOptions, admission: AdmittedPlatformArtGeneration, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; if admission.context != context @@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet { return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string()); } - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let runtime_context = standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( @@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at options: &PlatformArtAssetGenerationOptions, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { + if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet { + validate_platform_art_icon_prompt(prompt)?; + } enforce_project_permission_policy(root, "canvas.asset_generate")?; let persisted_runtime_state = runtime_context .map(|context| { @@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) .build() .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_prompt = build_platform_art_asset_prompt(prompt, options); let ( generated, canvas_context, @@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "/api/external/v1/editor/icon-spritesheets/generations", serde_json::json!({ "referenceId": reference_id, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "iconDescriptions": [generation_prompt], "sliceCount": options.slice_count, "sliceMode": options.slice_mode, "gridX": options.grid_x, @@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec( pub(crate) fn build_platform_art_asset_prompt( prompt: &str, - briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { if options.asset_kind == GameCreationAppAssetKind::Image { @@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } - let art_asset_brief = briefs - .iter() - .flat_map(|brief| brief.role_briefs.iter()) - .find(|role_brief| { - role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" - }) - .map(|role_brief| role_brief.markdown.trim()) - .filter(|markdown| !markdown.is_empty()) - .unwrap_or(prompt_text!("media.default_art_brief")); - format!( - prompt_text!("media.spritesheet_generation"), - truncate_inline(prompt, 240), - truncate_prompt_context(art_asset_brief) - ) + // 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。 + prompt.trim().to_string() } #[cfg(test)] @@ -8533,15 +8500,15 @@ mod canvas_generation_tests { } #[test] - fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() { - // 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同, - // 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。 + fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() { + // 图集描述由用户提供,工程化生图提示词由 API Server 添加。 let options = PlatformArtAssetGenerationOptions { asset_kind: GameCreationAppAssetKind::IconSpritesheet, ..PlatformArtAssetGenerationOptions::default() }; - assert!( - build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集") + assert_eq!( + build_platform_art_asset_prompt("原创收集玩法", &options), + "原创收集玩法" ); assert_eq!( platform_art_asset_art_spec(&options)["assetType"], @@ -8945,7 +8912,7 @@ mod canvas_generation_tests { asset_label: "失败后可重试素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options); + let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options); let runtime_context = standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false) .expect("standalone retry context"); @@ -9121,8 +9088,8 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); - let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options); let first = standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) .expect("first standalone slot context"); @@ -9556,7 +9523,7 @@ mod canvas_generation_tests { asset_label: "图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options); let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) .expect("current standalone context"); let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) @@ -9676,8 +9643,7 @@ mod canvas_generation_tests { asset_label: "另一个图标规范".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let other_prompt = - build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options); let other_context = standalone_platform_art_generation_runtime_context( &other_prompt, &other_options, @@ -10877,7 +10843,7 @@ mod canvas_generation_tests { let current_prompt = "恢复时使用同一个生成意图"; let current_options = PlatformArtAssetGenerationOptions::default(); let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化且必须原样重发的生成正文", "kind": "spec", @@ -11143,7 +11109,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "生成同一套整包美术"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11530,8 +11496,7 @@ mod canvas_generation_tests { asset_label: "账号隔离测试".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": generation_prompt, "kind": "spec", @@ -11739,7 +11704,7 @@ mod canvas_generation_tests { ..PlatformArtAssetGenerationOptions::default() }; let durable_generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + build_platform_art_asset_prompt(current_prompt, ¤t_options); let request_body = serde_json::json!({ "prompt": "持久化的原始生成正文", "kind": "spec", @@ -11883,7 +11848,7 @@ mod canvas_generation_tests { }; let options = PlatformArtAssetGenerationOptions::default(); let old_generation_prompt = - build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options); + build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None) .expect("prepare changed-intent binding access"); @@ -12050,7 +12015,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "保持同一个生成提示词"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None) .expect("prepare changed-reference binding access"); @@ -12313,8 +12278,7 @@ mod canvas_generation_tests { .expect("create accepted failure ledger access"); let current_prompt = "失败恢复提示词"; let current_options = PlatformArtAssetGenerationOptions::default(); - let generation_prompt = - build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); + let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -12517,7 +12481,7 @@ mod canvas_generation_tests { screen_color: None, }; let prompt = "恢复已受理视觉规范图"; - let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let generation_prompt = build_platform_art_asset_prompt(prompt, &options); let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account"); let frozen_platform_session = @@ -12831,21 +12795,32 @@ mod canvas_generation_tests { } #[test] - fn canonical_art_spritesheet_request_preserves_project_requirements() { - let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法"); - assert_eq!(descriptions.len(), 1); - assert!(descriptions[0].contains("原创收集玩法")); - assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定")); + fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() { + let options = PlatformArtAssetGenerationOptions { + asset_kind: GameCreationAppAssetKind::IconSpritesheet, + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}"; + validate_platform_art_icon_prompt(prompt).expect("valid multiline description"); + assert_eq!( + build_platform_art_asset_prompt(prompt, &options), + "金币\n木制宝箱\n银色钥匙" + ); } #[test] - fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { - let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); - - assert!(!descriptions.is_empty()); - assert!(descriptions - .iter() - .all(|description| description.chars().count() <= 200)); + fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() { + let boundary = "🪙".repeat(200); + validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n")) + .expect("200 Unicode characters after Rust trim must pass"); + let error = validate_platform_art_icon_prompt(&"🪙".repeat(201)) + .expect_err("201 Unicode characters must be rejected instead of truncated"); + assert!(error.contains("200"), "{error}"); + for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] { + assert!(validate_platform_art_icon_prompt(blank).is_err()); + } + validate_platform_art_icon_prompt("\u{feff}") + .expect("BOM is not whitespace under the API Server Rust trim contract"); } #[tokio::test] 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 bf71cb451..55ffaa90a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4963,6 +4963,9 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + crate::agent::validate_platform_art_icon_prompt(prompt)?; + } // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 let reference_asset_ids = @@ -4972,7 +4975,12 @@ pub(crate) fn prepare_local_project_asset_generation( let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), - prompt: local_project_asset_prompt(prompt)?, + prompt: if asset_kind == GameCreationAppAssetKind::IconSpritesheet { + // 图集只采用 API Server 的 trim + 单条长度合同,不套普通图片的控制字符限制。 + prompt.trim().to_string() + } else { + local_project_asset_prompt(prompt)? + }, options: PlatformArtAssetGenerationOptions { output_path: local_project_asset_single_line( output_path, @@ -5076,6 +5084,28 @@ mod local_project_asset_generation_tests { ) } + #[test] + fn icon_prompt_matches_api_server_single_description_contract() { + for prompt in [ + " 金币\n\n宝箱\t钥匙 ".to_string(), + "😀".repeat(200), + "\u{85}金币\u{85}".to_string(), + "\u{feff}".to_string(), + "金币\u{0}宝箱".to_string(), + ] { + assert_eq!( + prepare("icon-spritesheet", &prompt) + .expect("valid server description") + .prompt, + prompt.trim() + ); + } + for prompt in ["\u{85}\n ".to_string(), "😀".repeat(201)] { + assert!(prepare("icon-spritesheet", &prompt).is_err()); + } + assert!(prepare("image", &"图".repeat(201)).is_ok()); + } + #[test] fn toolbar_kinds_all_reach_the_shared_generation_channel() { for kind in [ 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 dfc79670e..6e9abb9e8 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 @@ -3553,7 +3553,8 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th { let root = unique_project_path(); let config_dir = unique_project_path(); - let base_url = spawn_mock_external_canvas_generation_api_server(None); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); let _platform_session = crate::platform_session::install_test_platform_session( "toolbar-spritesheet-user", "editor-toolbar-spritesheet-key", @@ -3608,7 +3609,7 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th let asset = generate_local_project_asset( root.to_string_lossy().into_owned(), "icon-spritesheet".to_string(), - "原创收集玩法素材图集".to_string(), + "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}".to_string(), Some("1:1".to_string()), Some("1K".to_string()), Some("游戏首版图集".to_string()), @@ -3619,6 +3620,23 @@ async fn generate_local_project_asset_command_generates_icon_spritesheet_from_th .await .expect("toolbar spritesheet generation"); + let generation_request = request_receiver + .try_iter() + .find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations ")) + .expect("captured toolbar spritesheet request"); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!( + generation_body["iconDescriptions"], + serde_json::json!(["金币\n木制宝箱\n银色钥匙"]), + "客户端只去除首尾空白,完整保留用户描述,不注入或截断提示词" + ); + 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()) @@ -6369,7 +6387,6 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", - &[], &options, ); for expected in [ @@ -6411,7 +6428,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { } #[test] -fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { +fn art_spritesheet_generation_prompt_preserves_user_description() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: GameCreationAppAssetKind::IconSpritesheet, @@ -6420,21 +6437,12 @@ fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_de }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物", - &[], &options, ); - for expected in [ - "原创透明核心美术素材图集", - "严格从用户需求和美术 brief 提取", - "素材类别与数量以当前项目需求为准", - "蛇头、直身、转角、尾部和四类食物", - "角色轮廓、图标排布与配色采用项目原创设计", - ] { - assert!( - prompt.contains(expected), - "art generation prompt missing {expected}" - ); - } + assert_eq!( + prompt, + "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物" + ); let art_spec = platform_art_asset_art_spec(&options).to_string(); for expected in [ 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 index e1e5f215d..d7c5e859c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -17,6 +17,7 @@ import type { GameIterationVersion, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; +import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/image-editor/editorProjectClient'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput'; @@ -229,9 +230,17 @@ export function ResourceCanvasAssetGenerationPanelView({ }, [], ); - // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust - // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 - const promptMaxLength = resourceEditPromptMaxLength('image-reference'); + // 图集原文作为单条 iconDescriptions,复用网站合同;其余图片入口保持原上限。 + const promptMaxLength = + action.assetKind === 'icon-spritesheet' + ? EDITOR_ICON_DESCRIPTION_MAX_CHARS + : resourceEditPromptMaxLength('image-reference'); + // Rust str::trim 使用 Unicode White_Space;JS trim 对 NEL/BOM 的定义不同。 + const normalizePrompt = (value: string) => + action.assetKind === 'icon-spritesheet' + ? value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '') + : value.trim(); + const normalizedPrompt = normalizePrompt(prompt); /** * 参考选择只对有真实参考能力的入口呈现。 * @@ -279,9 +288,13 @@ export function ResourceCanvasAssetGenerationPanelView({ assets: assets ?? [], }) : []; - const promptTooLong = prompt.trim().length > promptMaxLength; + const promptLength = + action.assetKind === 'icon-spritesheet' + ? Array.from(normalizedPrompt).length + : normalizedPrompt.length; + const promptTooLong = promptLength > promptMaxLength; const promptTooLongError = promptTooLong - ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` + ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${promptLength} 个` : null; /** * 重试面板里「输入被改动过」的判据。 @@ -296,12 +309,12 @@ export function ResourceCanvasAssetGenerationPanelView({ boundRequestRef.current.referenceIds, referenceAssetIds, ) || - prompt.trim() !== boundRequestRef.current.prompt.trim() || + normalizedPrompt !== normalizePrompt(boundRequestRef.current.prompt) || assetName.trim() !== boundRequestRef.current.assetName.trim() || aspectRatio !== boundRequestRef.current.aspectRatio || imageSize !== boundRequestRef.current.imageSize); const canSubmit = - prompt.trim().length > 0 && + normalizedPrompt.length > 0 && assetName.trim().length > 0 && !referenceError && !referenceIssue && @@ -359,7 +372,6 @@ export function ResourceCanvasAssetGenerationPanelView({ function submit(event: FormEvent) { event.preventDefault(); - const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); // 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。 if ( @@ -456,7 +468,6 @@ export function ResourceCanvasAssetGenerationPanelView({ aria-label="生成提示词" rows={3} autoFocus - maxLength={promptMaxLength} placeholder={action.promptPlaceholder} value={prompt} onChange={(event) => setPrompt(event.currentTarget.value)} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index db0e195da..279df287d 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, render, screen, within } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -398,7 +404,7 @@ describe('生成面板的参考接线', () => { action={spritesheetAction} assets={[asset('asset-a', 'image/png', 'assets/a.png')]} draft={{ - prompt: '一套敌人头像', + prompt: ' 金币\n\n宝箱\t钥匙 ', assetName: '图标素材', aspectRatio: '1:1', imageSize: '1K', @@ -438,10 +444,59 @@ describe('生成面板的参考接线', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ kind: 'icon-spritesheet', + prompt: '金币\n\n宝箱\t钥匙', references: [], }); }); + test.each([ + ['😀'.repeat(200), true, '😀'.repeat(200)], + ['😀'.repeat(201), false, '😀'.repeat(201)], + ['\u0085金币\u0085', true, '金币'], + ['\u0085\n ', false, ''], + ['\uFEFF', true, '\uFEFF'], + ])( + '图集描述按 API 的 Unicode 字符与空白语义校验(%#)', + (prompt, accepted, expected) => { + const onSubmit = vi.fn(); + render( + undefined} + />, + ); + const input = screen.getByLabelText('生成提示词') as HTMLTextAreaElement; + fireEvent.change(input, { target: { value: prompt } }); + expect(input.value).toBe(prompt); + expect(input.hasAttribute('maxlength')).toBe(false); + const button = screen.getByRole('button', { + name: '生成图标素材', + }) as HTMLButtonElement; + expect(button.disabled).toBe(!accepted); + fireEvent.submit(input.closest('form')!); + if (accepted) { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ prompt: expected }), + ); + } else { + expect(onSubmit).not.toHaveBeenCalled(); + if (Array.from(prompt).length > 200) { + expect( + screen.getByText('生成提示词最多 200 个字符,当前 201 个'), + ).not.toBeNull(); + } + } + }, + ); + test('点提示词输入区不该自己弹出素材选择框', async () => { const user = userEvent.setup(); const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c5c8101dd..6069537b2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9053,7 +9053,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(矩阵):`ui-interaction` = 生成图片 / 生成规范(图标规范、自定义规范)/ 生成图标素材 / 生成 UI 设计图 / 上传;`character` = 生成图片 / 生成规范(角色规范、自定义规范)/ 生成角色形象 / 上传;`scene` = 生成图片 / 生成规范(自定义规范)/ 上传;`audio` = 生成背景音乐 / 生成音效 / 上传。文档、待归类、项目版本、「所有资源」展开态与资源总览**不渲染**工具栏(`resolveResourceCanvasBottomTools` 返回空数组,渲染判据 `resourceBookState.view === 'child'` 且 category 命中四个栏目)。工具项与顺序是纯数据表,事实源落在 `resourceCanvasBottomToolbarModel.ts`。 - 决策(落点与几何):工具栏是 `.game-resource-book-manager` 的**直接子节点**(与画本场景、右下 Dock、通知条并列,不进带 `scale()` 的场景层),锚在**左下角**(`left/bottom: 14px; z-index: 40`)——右下角是既有缩放 / 撤销 Dock,左下角是画布上唯一两者都不占的稳定空位;外壳消费共享 `packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`(AGC 侧第一次真正消费这组共享 chrome)。一处必要覆盖:共享 `.genarrative-image-canvas__toolbar` 是横向滚动容器,会裁掉它自己弹出的二级菜单,消费端改成 `overflow: visible` + `flex-wrap: wrap`。 - 决策(接线与收敛):图片类入口 `generate_local_project_asset`(kind 映射:图片 `image` / 角色形象 `character` / 图标规范 `icon-spec` / 角色规范与自定义规范 `spec` / 图标素材 `art-spritesheet` / UI 设计图 `ui-prototype`);音频入口复用既有 `derive_local_project_resource` 无源生成链路(**不另写一份音频生成**);上传复用 `upload_local_asset`。两者成功后的刷新形状统一为「**配对读 `(revision, manifest)`** → `onManifestChange` → `pendingResourceFocusRef` 定位新卡」,不重算依赖图、不另写布局。**音频入口收敛**:既有「生成素材」浮层入口改为只保留视频(`ResourceCanvasGenerationPanelView` 新增 `kinds` prop,单类型入口不再渲染类型选择器,面板标题与提交文案跟类型走),视频能力不删只是不进工具栏。 -- 决策(参数口径,避免假控件):比例 / 尺寸选项与默认值复用网页端纯模型 `ImageCanvasGenerationModel.ts`,但按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`,照搬就是一个点了必失败的选项);提示词上限复用 `resourceEditPromptMaxLength`(32000,与 Rust `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 同口径);**面板不渲染模型选择器**(本地 IPC 没有 `model` 入参)。规范入口是固定档:只读展示规格,不给点了不生效的比例控件。 +- 决策(参数口径,避免假控件):比例 / 尺寸选项与默认值复用网页端纯模型 `ImageCanvasGenerationModel.ts`,但按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`,照搬就是一个点了必失败的选项);图标素材描述去除首尾空白后最多 `200` 个 Unicode 字符,作为唯一 `iconDescriptions` 元素原样提交,不包装、截断或拆条;其余图片提示词上限为 `32000`,前端与 Rust 原生校验保持一致;**面板不渲染模型选择器**(本地 IPC 没有 `model` 入参)。规范入口是固定档:只读展示规格,不给点了不生效的比例控件。 - 决策(前置规范图,本轮的关键设计):`ui-prototype` 与 `art-spritesheet` 在 Rust 侧要求项目里已有登记并绑定当前账号的 `assets/art-spec.png`(`local_path` 精确匹配 + `kind == icon-spec` + 图片媒体类型 + 画布来源)。前端判据 `projectHasIconSpecReference` 与它逐字对齐;缺前置时入口**保持可点击**并给可执行原因(`aria-disabled` 而非原生 `disabled`,文案含 `assets/art-spec.png` 与「生成规范 → 图标规范」),且零生成请求。**同时**把「图标规范」入口在缺前置时按 `assets/art-spec.png` 落盘(`outputPath`),否则那两条入口会被前置条件永久锁死、工具栏自身无法满足自己的前置;已有权威规范图时不再传 `outputPath`(Rust `replace_existing` 固定 `false`,指向已存在文件会被硬拒),改为生成一张新的普通图标规范资产。这条是**前端策略**,不扩接口。 - 已知能力缺口(用户口径是「缺参数就停下汇报,不自行扩接口」,记账备查):本地 IPC 没有 ① `model` ② `specType` ③ `replaceExisting` 入参。后果:面板不给模型选择;**角色规范与自定义规范共用 `spec` 通道**(靠 `assetName` / 提示词区分),因此「角色规范」不是真正的角色设定板通道;权威规范图存在时无法重写它。另:网页端 composer 子视图(含 `ImageCanvasSpecGenerationPanelView` 直连 `/api/editor/llm/icon-specs/*`)**没有复用**,因为 AGC 无该 BFF 通道且本地 IPC 不接受模型 / 参考图入参,照搬会渲染改不了请求的控件——本轮只把**纯模型**接进来。 - 影响范围:`apps/ai-game-creator-shell/src/features/resource-canvas/{resourceCanvasBottomToolbarModel.ts,ResourceCanvasBottomToolbarView.tsx,ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,resourceCanvasChrome.css}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、测试 `apps/ai-game-creator-shell/tests/{resourceCanvasBottomToolbar.test.tsx(新增),resourceCanvasGenerationEntry.test.tsx,projectResourceLiveIntegration.test.tsx,appSurface/project-development.suite.ts}`、PRD §3.10 / §7.9 / §8、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(新增)、验收用例 S11 / S11a。**未动**:Rust、external v1 / OpenAPI、`packages/`(只消费共享 chrome)、SpacetimeDB。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 584560c10..db6ea4beb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,12 @@ # 踩坑与排障记录 +## 客户端图标素材描述不能先包装再截断 + +- 现象:资源画布填写了具体图标需求,平台实际收到的 `iconDescriptions` 却只包含泛化的小游戏美术指令,生成结果不遵循输入。 +- 原因:客户端先给用户描述套“Web 小游戏首版原型、核心美术素材”和默认 brief 模板,再把包装后的文本压入单项 `200` 字符;固定前缀已占满额度,用户描述在发给 API Server 之前就被丢弃。服务端校验只能看到被截短的文本,无法恢复原需求。 +- 处理:客户端画布图标生成只 trim 描述并以单项 `iconDescriptions` 原样提交,保留内部换行;面板与原生入口按 `200` 个 Unicode 码点校验并拒绝空白或超限输入,不静默截断、不机械拆条。服务端共用的提示词流程负责规范图、背景与排布要求;其它图片生成的 `32000` 字符上限保持不变。 +- 验证:检查实际请求体与 trim 后的原文一致,并覆盖 `200/201` 码点、补充平面字符、换行和空白输入;不能只断言“请求长度未超限”。完整合同见 [画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 + ## 2026-09-21 不同渠道的包体在同一台设备安装会互相顶掉 - **现象**:在一台已经装了某个渠道 AGC 客户端的设备上安装另一个渠道的安装包,装完后旧客户端直接消失(安装目录被覆盖、卸载项被接管),更新端点、平台服务器与本地登录态一起换成新渠道的;两个渠道的客户端无法共存。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ecf303748..f2f41c0c2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -212,6 +212,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation ## 资源画布生成、展示与布局合同 +- 客户端「生成图标素材」只将用户描述去除首尾空白后,保留内部换行并作为唯一 `iconDescriptions` 元素提交;不添加小游戏首版原型、核心美术或默认美术 brief,不截断、不按段落或字数拆条。面板与原生入口均要求描述非空且最多 `200` 个 Unicode 字符(按码点计数),超限在提交前明确拒绝;其余图片生成提示词维持 `32000` 字符上限。规范图、纯色背景、素材排布等生图约束由网站与客户端共用的 API Server 流程统一添加。完整请求合同见 [画板图标素材生成入口设计](../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 - 生成工具点击后先在当前栏目创建临时占位卡,并以卡片为锚点展示独立生成浮层;占位不登记为正式素材、不进入 Agent 可引用资源集。上传仍沿用文件选择,不创建虚假生成任务。关闭编辑浮层不应丢失正在执行的任务;切换项目不得将旧项目结果或草稿写入新项目。 - 图片生成支持从现有素材选择器添加真实参考;引用携带稳定资源身份并经既有原生权限、归属与类型校验传至生成链路,不能仅拼接名称。缺少规范图不阻止打开面板;确有规范前置的操作必须在提交前满足要求,不能绕过后端校验。用户可通过现有工具栏先生成规范图。 - 占位可移动。提交复用正式生成任务、幂等与结果登记链路;成功结果使用占位最新位置,失败保留输入与引用供重试。已受理但响应不确定时先对账,不能无条件再次发起付费生成。关闭、删除占位和后台任务的行为需保持既有任务所有权,不把隐藏展示当作取消任务。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index b362711d4..26a79ab3e 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-09-17` +更新时间:`2026-09-21` ## 背景 @@ -40,6 +40,8 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- AGC 客户端资源画布的「生成图标素材」与网站画布共用服务端图集生成与提示词流程。客户端只去除描述首尾空白,将保留内部换行的完整原文作为唯一 `iconDescriptions` 元素提交;不追加“Web 小游戏首版原型”“核心美术素材”或默认美术 brief,不静默截断,也不按换行或固定字数拆成多项绕过单项上限。 +- 客户端面板和原生提交均按去除首尾空白后的描述校验:不能为空,最多 `200` 个 Unicode 字符(按码点计数)。空白与 API Server 的 Rust `str::trim` 一致,使用 Unicode `White_Space`,不套用普通图片入口额外的控制字符限制。超限明确拒绝并保留输入全文,不能只截取前 `240` 字符或把包装后的提示词截进 `200` 字符;其它客户端图片生成仍保留 `32000` 字符上限。单项最多 `200` 码点自然满足现有数组、聚合字符与 UTF-8 字节上限。服务端的规范图、背景、排列和切图要求在描述校验后统一加入,不占请求描述的长度额度;现有 API 数组与聚合上限不变。 - 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。 - 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。 - `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。 @@ -110,6 +112,7 @@ - 点击 `生成图标素材` 后出现一叠空白图标占位和图标素材面板。 - `图标规范 -> 从画布中选择` 只能选择图标规范图,点击普通图片或角色规范图不会绑定。 - 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。 +- 客户端提交含中文、补充平面字符和内部换行的合法描述时,`iconDescriptions` 只有一个元素且等于去除首尾空白后的原文;`200` 个 Unicode 字符通过,`201` 个字符和全空白描述在付费提交前被拒绝。其它图片入口继续接受大于 `200`、不超过 `32000` 字符的提示词。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 - 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,直接持久化整数倍放大后的透明图集;该图集宽高比等于逻辑图、尺寸接近规整输入,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。