合并最新 master 到图片生成 CI 修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 5m5s
Project CI / Frontend tests (pull_request) Successful in 5m40s
Project CI / Backend tests (pull_request) Successful in 8m16s
Project CI / Native shell tests (pull_request) Successful in 9m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m45s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m52s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m36s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 5m5s
Project CI / Frontend tests (pull_request) Successful in 5m40s
Project CI / Backend tests (pull_request) Successful in 8m16s
Project CI / Native shell tests (pull_request) Successful in 9m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m45s
合入最新 master 的客户端图标素材描述修复 保留图片生成通知测试的宿主执行会话初始化与失败断言
This commit is contained in:
@@ -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/ 且不能覆盖已有文件",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<String> {
|
||||
// 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::<Vec<_>>().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::<String>();
|
||||
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<AdmittedPlatformArtGeneration, String> {
|
||||
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<GeneratedPlatformArtAsset, String> {
|
||||
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<GeneratedPlatformArtAsset, 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(
|
||||
@@ -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<PreparedPlatformArtAssetGeneration, String> {
|
||||
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]
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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 [
|
||||
|
||||
+20
-9
@@ -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<HTMLFormElement>) {
|
||||
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)}
|
||||
|
||||
@@ -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(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={spritesheetAction}
|
||||
draft={{
|
||||
prompt: '',
|
||||
assetName: '图标素材',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references: [],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => 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');
|
||||
|
||||
Reference in New Issue
Block a user