修复图标规范评审问题

迁移旧版 ui 规范快照并隔离跨对话框优化结果。

关闭非幂等图标规范生成重试并统一参考图解析逻辑。

限制 LLM 重试范围并严格校验优化文本与补全参数。

修正图集键色、数量与素材间距提示约束并补充行为测试。

同步图片画布与生成面板技术文档。
This commit is contained in:
2026-08-05 10:55:09 +08:00
parent f5035af504
commit ac12e929f2
14 changed files with 343 additions and 124 deletions
@@ -91,14 +91,14 @@
- 角色、图标 spritesheet 与 UI 素材提取共用 provider 原图 source-only 收口:BgFilter 最终失败、Alpha 比例漂移超过 `5%`、provider 原图修复性回读失败、Alpha 回贴失败或透明图完整解码失败时,只把已保存 provider 原图作为唯一主图完成占位,以 `completed + warning` 收口且不退款;图标 / UI 固定 `iconImageSrcs=[]``sliceWarning=null`,不写透明图、不拆分,也不创建透明图或切片画布层。provider 原图本身无法完整解码时在首次持久化前失败,不得用 `512×512` 伪造元数据。图标自动拆分、手动拆分和 UI 提取共用有界处理链:全部 flood-fill 原始连通域最多 `4096` 个,辅助部件合并使用空间网格邻近候选,`maxOutputSlices=64` 和所有 padding 后 crop 的总像素预算都在首片 PNG 编码前检查。prepare 只保留一张 RGBA 与排好序的 bounds,不再一次返回最多 `64` 份 PNGapi-server 按需编码并以容量 `2` 的有界管线上传,使单个图集同时只保留整图和最多两份切片 PNG。CPU 工作继续受 2 路 semaphore、30 秒本地上限与请求 deadline 保护;独立内存 admission 从 prepare 持有到最后一片上传结束,慢 OSS 不得占用 CPU permit,也不得绕过内存限制堆积新批次。自动超限保留可信透明整图、返回稳定 `sliceWarning` 且不写任何切片;手动超限在首次持久化前返回 `422`
- `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`
- 图标规范专用链路(2026-08-04)取代本文旧的前端 prompt / `ui` 规范分支口径。前端规范类型只允许 `character / icon / custom`;图标规范表单状态和请求字段使用 `playSetting / artStyle`,对应 Rust 字段为 `play_setting / art_style`,界面与 `generationInputs.fields[]` 标题继续使用「玩法设定 / 美术风格」,不保留 `ui` 类型。
- 图标规范专用链路(2026-08-04)取代本文旧的前端 prompt / `ui` 规范分支口径。前端规范类型只允许 `character / icon / custom`;图标规范表单状态和请求字段使用 `playSetting / artStyle`,对应 Rust 字段为 `play_setting / art_style`,界面与 `generationInputs.fields[]` 标题继续使用「玩法设定 / 美术风格」。恢复历史画布快照时必须把旧 `specType="ui"` 迁移为 `icon`,运行时类型守卫不得继续把 `ui` 当成现役类型。
- `POST /api/editor/llm/icon-specs/refine-game-play``POST /api/editor/llm/icon-specs/refine-art-style`:分别接收 `{ playSetting }` / `{ artStyle }`,由 api-server 使用 `state.llm_client()` 构造业务 prompt。两个字段各自维护处理中和单次撤销快照;一项处理中不锁另一项,但任一项处理中禁止提交生成。
- 图标规范的 `playSetting / artStyle` 单字段上限统一为 `200` 个 Unicode 字符。前端图标规范文本域设置 `maxLength`按 Unicode 字符截断输入非法恢复态禁用优化与生成editor client 在优化请求、优化响应和最终生成提交前再次拒绝超长值api-server 对两个优化入口和最终图标规范生成入口都执行同一上限校验LLM 优化结果超长视为无效结果并进入既有三次重试,第三次仍超长返回 `502`,不能只依赖 prompt 中的长度要求
- 上述两个 refine 调用与生成前的 `ExtraParam` 补全均最多执行 3 次完整 LLM 尝试;调用错误、空文本和补全结果非法 JSON 在次数内重试,最终失败统一返回 `AppError`。业务层执行重试时关闭 `LlmClient` 自身的内层重试,避免配置重试与业务重试相乘。
- `ExtraParam` 补全 prompt 只把 `playSetting / artStyle` 作为待分析数据,要求 LLM 直接返回且只返回 `{ genre, theme, useCase, targetUser }` JSON 对象。`genre` 必须取 `GameGenre::as_slug()` 定义的 19 个中文值之一,`GameGenre` 的自定义 Serde 也统一按该中文值读写,不接受英文枚举名;`theme` 是可组合、可扩展的中文题材;`useCase` 表示 `PC / mobile / console / Web / handheld` 等实际调用平台;`targetUser` 为结合玩法与美术推断的自由中文用户描述。四项均为非空字符串,不接受 Markdown、数组、`null` 或额外字段。
- 图标规范的 `playSetting / artStyle` 单字段上限统一为 `200` 个 Unicode 字符。浏览器原生 `maxLength` 按 UTF-16 码元计数,与该业务口径不一致,因此图标规范文本域设置 `maxLength`,只通过按 Unicode 字符截断`onChange` 和提交校验限制输入非法恢复态禁用优化与生成editor client 在优化请求、优化响应和最终生成提交前再次拒绝超长值api-server 对两个优化入口和最终图标规范生成入口都执行同一上限校验LLM 优化结果必须是无标题、解释、Markdown 或 JSON 的可直接使用纯文本,允许分段和换行;非法格式与空文本、超长文本一样作为非法模型输出重试,第三次仍非法返回 `502`
- 上述两个 refine 调用与生成前的 `ExtraParam` 补全均最多执行 3 次完整 LLM 尝试;空文本、格式非法或补全结果非法 JSON 在次数内重试。调用错误只对 timeout、connectivity、transport、上游 `408 / 429 / 5xx` 重试,配置、请求、上游其它 `4xx` 等永久错误立即返回。业务层执行重试时关闭 `LlmClient` 自身的内层重试,避免配置重试与业务重试相乘。
- `ExtraParam` 补全 prompt 只把 `playSetting / artStyle` 作为待分析数据,要求 LLM 直接返回且只返回 `{ genre, theme, useCase, targetUser }` JSON 对象。`genre` 必须取 `GameGenre::as_slug()` 定义的 19 个中文值之一,`GameGenre` 的自定义 Serde 也统一按该中文值读写,不接受英文枚举名;`theme` 是可组合、可扩展的中文题材;`useCase` 表示 `PC / mobile / console / Web / handheld` 等实际调用平台;`targetUser` 为结合玩法与美术推断的自由中文用户描述。四项均为非空且不超过 `200` 个 Unicode 字符的字符串,解析后去除首尾空白,不接受 Markdown、数组、`null` 或额外字段。
- `POST /api/editor/icon-specs/generations`:业务字段只有 `playSetting / artStyle`;参考图、项目、素材文件夹和 `canvasCompletion` 继续使用统一生成包络。前端不得提交最终 prompt、`kind``assetKind`。api-server 必须先补全 `ExtraParam`,再构造最终 prompt,随后固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 调用既有图片生成分发;队列仍只使用 `editor_image_generation`。服务端重建 `generationInputs.fields[]` 为「玩法设定 / 美术风格 / 游戏类型」,其中游戏类型保存 `GameGenre::as_slug()` 返回的中文值;`theme / useCase / targetUser` 只参与 prompt,不进入 metadata。
- 上述图标规范 refine、参数补全、最终图片 prompt 和 genre 映射的准确业务文本仍是延期输入。在文本到位前,未完成的 prompt builder 必须直接使用 Rust `todo!()` 标记,不得自造 fallback 或伪错误协议;其它类型、路由、队列、UI 和测试框架继续保持可验证。
- 图标 spritesheet 只按 `referenceImageSrc` 匹配当前账号 `assetKind=icon-spec` 的项目资源或素材,并且只读取其 `generationInputs.fields[]` 中标题精确等于「游戏类型」的 genre slug;不读取其它玩法字段,不沿 `sourceResourceId` 链推断。正式 genre 映射和注入位置到位前,builder 必须逐字返回现有 spritesheet prompt
- 图标 spritesheet 只按 `referenceImageSrc` 匹配当前账号 `assetKind=icon-spec` 的项目资源或素材,并且只读取其 `generationInputs.fields[]` 中标题精确等于「游戏类型」的 genre slug;不读取其它玩法字段,不沿 `sourceResourceId` 链推断。prompt 必须使用本次实际键色限制主体描边、底板、投影、发光和反光,不得硬编码绿色;按用户描述顺序一一生成,不要求图标数为二的幂,也不得遗漏或补充;相邻图标之间必须保持空白,禁止描边、底板、阴影、装饰或特效连接,以保留可拆分边界
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
- `POST /api/editor/projects`:创建图片画布工程。
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
@@ -40,7 +40,7 @@
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 图标规范只使用 `specType="icon"`,表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销、失败保留文本与旧撤销快照。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。
10. 图标规范只使用 `specType="icon"`历史 `specType="ui"` 快照在恢复边界迁移为 `icon`表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销、失败保留文本与旧撤销快照。优化请求必须绑定发起时的生成对象 ID 和请求代次;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。
11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;后端固定图片参数并复用 `editor_image_generation`
12. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`
13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
@@ -66,9 +66,8 @@ use crate::{
enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller,
},
editor_green_screen::{
EditorScreenBackgroundColor, editor_green_screen_asset_prompt_clause,
editor_green_screen_character_prompt_clause, editor_ui_design_asset_extraction_prompt,
remove_editor_generated_green_screen_background,
EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause,
editor_ui_design_asset_extraction_prompt, remove_editor_generated_green_screen_background,
},
editor_screen_background_decision::{
EditorScreenBackgroundDecision, EditorScreenBackgroundDecisionInput,
@@ -5030,7 +5029,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.await?;
let screen_color = screen_background_decision.color;
let prompt = crate::prompt::icon_spec::build_spritesheet_prompt(
user_prompt.clone(),
user_prompt.as_str(),
screen_color,
icon_spec_genre,
);
@@ -19,7 +19,7 @@ use crate::{
sanitize_editor_client_generation_inputs,
},
http_error::AppError,
platform_errors::map_llm_error,
platform_errors::{is_retryable_llm_error, map_llm_error},
prompt::icon_spec::{PROMPT_MAX_LEN, build_extra_param_prompt, get_gen_icon_spec_prompt},
request_context::RequestContext,
state::AppState,
@@ -30,6 +30,7 @@ const ICON_SPEC_ASPECT_RATIO: &str = "16:9";
const ICON_SPEC_IMAGE_SIZE: &str = "2K";
const ICON_SPEC_SIZE: &str = "2048x1152";
const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3;
const ICON_SPEC_EXTRA_PARAM_MAX_LEN: usize = 200;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -39,7 +40,7 @@ pub(crate) struct RequestParam {
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct ExtraParam {
pub(crate) genre: GameGenre,
pub(crate) theme: String,
@@ -203,33 +204,43 @@ pub(crate) async fn generate_icon_spec(
payload.generation_inputs.take(),
);
let image_request = build_icon_spec_image_request(payload, final_prompt, generation_inputs);
generate_editor_image(
State(state),
Extension(request_context),
Extension(authenticated),
Ok(Json(EditorImageGenerationRequest {
prompt: final_prompt,
size: Some(ICON_SPEC_SIZE.to_string()),
kind: Some("spec".to_string()),
style: None,
model: Some(ICON_SPEC_MODEL.to_string()),
screen_color: None,
seg_model: None,
aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()),
image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()),
reference_image_srcs: payload.reference_image_srcs,
project_id: payload.project_id,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: Some(generation_inputs),
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
source_resource_id: payload.source_resource_id,
canvas_completion: payload.canvas_completion,
})),
Ok(Json(image_request)),
)
.await
}
fn build_icon_spec_image_request(
payload: EditorIconSpecGenerationRequest,
prompt: String,
generation_inputs: Value,
) -> EditorImageGenerationRequest {
EditorImageGenerationRequest {
prompt,
size: Some(ICON_SPEC_SIZE.to_string()),
kind: Some("spec".to_string()),
style: None,
model: Some(ICON_SPEC_MODEL.to_string()),
screen_color: None,
seg_model: None,
aspect_ratio: Some(ICON_SPEC_ASPECT_RATIO.to_string()),
image_size: Some(ICON_SPEC_IMAGE_SIZE.to_string()),
reference_image_srcs: payload.reference_image_srcs,
project_id: payload.project_id,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: Some(generation_inputs),
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
source_resource_id: payload.source_resource_id,
canvas_completion: payload.canvas_completion,
}
}
async fn run_icon_spec_extra_param_llm(
state: &AppState,
prompt: String,
@@ -259,7 +270,7 @@ async fn run_icon_spec_extra_param_llm(
));
}
} else {
match serde_json::from_str::<ExtraParam>(text) {
match parse_extra_param(text) {
Ok(extra_param) => return Ok(extra_param),
Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {}
Err(_) => {
@@ -273,17 +284,36 @@ async fn run_icon_spec_extra_param_llm(
}
}
}
Err(error) => {
Err(error) if is_retryable_llm_error(&error) => {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(map_llm_error(error));
}
}
Err(error) => return Err(map_llm_error(error)),
}
sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await;
}
unreachable!("icon spec extra param retry loop always returns")
}
fn parse_extra_param(text: &str) -> Result<ExtraParam, ()> {
let mut extra_param = serde_json::from_str::<ExtraParam>(text).map_err(|_| ())?;
for value in [
&mut extra_param.theme,
&mut extra_param.use_case,
&mut extra_param.target_user,
] {
let normalized = value.trim();
if normalized.is_empty() || normalized.chars().count() > ICON_SPEC_EXTRA_PARAM_MAX_LEN {
return Err(());
}
if normalized.len() != value.len() {
*value = normalized.to_string();
}
}
Ok(extra_param)
}
async fn sleep_before_icon_spec_llm_retry(retry_backoff_ms: u64, attempt: u32) {
let delay_ms = retry_backoff_ms.saturating_mul(u64::from(attempt));
if delay_ms > 0 {
@@ -429,6 +459,35 @@ mod tests {
assert!(inputs.get("targetUser").is_none());
}
#[test]
fn extra_param_requires_exact_non_empty_bounded_fields() {
let valid = parse_extra_param(
r#"{"genre":"策略","theme":" 现代军事 ","useCase":"PC、mobile","targetUser":"策略游戏玩家"}"#,
)
.expect("valid extra params should parse");
assert_eq!(valid.theme, "现代军事");
assert!(
parse_extra_param(
r#"{"genre":"策略","theme":"","useCase":"PC","targetUser":"策略游戏玩家"}"#
)
.is_err()
);
assert!(
parse_extra_param(
r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家","extra":"unexpected"}"#
)
.is_err()
);
let oversized = json!({
"genre": "策略",
"theme": "".repeat(ICON_SPEC_EXTRA_PARAM_MAX_LEN + 1),
"useCase": "PC",
"targetUser": "策略游戏玩家",
});
assert!(parse_extra_param(oversized.to_string().as_str()).is_err());
}
#[test]
fn icon_spec_generation_text_enforces_prompt_max_length() {
assert!(
@@ -478,27 +537,48 @@ mod tests {
}
#[test]
fn generation_dispatch_uses_existing_image_job_kind() {
let source = include_str!("editor_project_icon_specs.rs");
let production_source = source
.split("#[cfg(test)]")
.next()
.expect("production source before tests");
let handler = production_source
.split("pub(crate) async fn generate_icon_spec")
.nth(1)
.expect("generation handler source");
let llm_index = handler
.find("run_icon_spec_extra_param_llm")
.expect("extra param llm call");
let prompt_index = handler
.find("get_gen_icon_spec_prompt(&payload.request_param, &extra_param)")
.expect("final prompt generation");
let dispatch_index = handler
.find("generate_editor_image(")
.expect("existing image generation dispatch");
assert!(llm_index < prompt_index && prompt_index < dispatch_index);
assert!(!production_source.contains("editor_icon_spec_generation"));
assert!(!production_source.contains("EDITOR_ICON_SPEC_GENERATION_JOB_KIND"));
fn generation_dispatch_builds_the_existing_image_job_contract() {
let image_request = build_icon_spec_image_request(
EditorIconSpecGenerationRequest {
request_param: RequestParam {
play_setting: "回合制占点".to_string(),
art_style: "低多边形".to_string(),
},
reference_image_srcs: Some(vec!["resource-reference-1".to_string()]),
project_id: Some("project-1".to_string()),
generation_inputs: None,
asset_folder_id: Some("project".to_string()),
asset_label: Some("图标规范 1".to_string()),
source_resource_id: Some("resource-source-1".to_string()),
canvas_completion: None,
},
"最终图标规范 prompt".to_string(),
json!({ "fields": [], "references": [] }),
);
assert_eq!(image_request.prompt, "最终图标规范 prompt");
assert_eq!(image_request.kind.as_deref(), Some("spec"));
assert_eq!(image_request.asset_kind.as_deref(), Some("icon-spec"));
assert_eq!(image_request.model.as_deref(), Some(ICON_SPEC_MODEL));
assert_eq!(image_request.size.as_deref(), Some(ICON_SPEC_SIZE));
assert_eq!(
image_request.aspect_ratio.as_deref(),
Some(ICON_SPEC_ASPECT_RATIO)
);
assert_eq!(
image_request.image_size.as_deref(),
Some(ICON_SPEC_IMAGE_SIZE)
);
assert_eq!(
image_request.reference_image_srcs,
Some(vec!["resource-reference-1".to_string()])
);
assert_eq!(image_request.project_id.as_deref(), Some("project-1"));
assert_eq!(image_request.asset_folder_id.as_deref(), Some("project"));
assert_eq!(image_request.asset_label.as_deref(), Some("图标规范 1"));
assert_eq!(
image_request.source_resource_id.as_deref(),
Some("resource-source-1")
);
}
}
@@ -13,7 +13,7 @@ use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_llm_error,
platform_errors::{is_retryable_llm_error, map_llm_error},
prompt::icon_spec::{
PROMPT_MAX_LEN, build_refine_art_style_prompt, build_refine_play_setting_prompt,
},
@@ -99,6 +99,7 @@ fn require_refine_text(
enum RefinedTextError {
Empty,
TooLong,
InvalidFormat,
}
impl RefinedTextError {
@@ -106,6 +107,7 @@ impl RefinedTextError {
match self {
Self::Empty => "LLM 未返回可用文本".to_string(),
Self::TooLong => format!("LLM 返回文本超过 {PROMPT_MAX_LEN} 个字符"),
Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(),
}
}
}
@@ -118,6 +120,26 @@ fn normalize_refined_text(value: &str) -> Result<String, RefinedTextError> {
if value.chars().count() > PROMPT_MAX_LEN {
return Err(RefinedTextError::TooLong);
}
let disallowed_prefixes = [
"```",
"#",
"> ",
"- ",
"* ",
"说明:",
"说明:",
"解释:",
"解释:",
];
let has_disallowed_line = value.lines().any(|line| {
let line = line.trim_start();
disallowed_prefixes
.iter()
.any(|prefix| line.starts_with(prefix))
});
if value.contains("```") || value.starts_with(['{', '[']) || has_disallowed_line {
return Err(RefinedTextError::InvalidFormat);
}
Ok(value.to_string())
}
@@ -147,11 +169,12 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String,
}
Err(_) => {}
},
Err(error) => {
Err(error) if is_retryable_llm_error(&error) => {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(map_llm_error(error));
}
}
Err(error) => return Err(map_llm_error(error)),
}
sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await;
}
@@ -196,4 +219,30 @@ mod tests {
assert!(normalize_refined_text("".repeat(PROMPT_MAX_LEN).as_str()).is_ok());
assert!(normalize_refined_text("".repeat(PROMPT_MAX_LEN + 1).as_str()).is_err());
}
#[test]
fn refined_text_rejects_structured_or_explanatory_output() {
assert_eq!(
normalize_refined_text("```json\n{}\n```").unwrap_err(),
RefinedTextError::InvalidFormat
);
assert_eq!(
normalize_refined_text("说明:优化后的玩法").unwrap_err(),
RefinedTextError::InvalidFormat
);
assert_eq!(
normalize_refined_text(r#"{"playSetting":"回合制"}"#).unwrap_err(),
RefinedTextError::InvalidFormat
);
assert_eq!(
normalize_refined_text("回合制占点,围绕资源调度形成攻防循环")
.expect("plain text should pass"),
"回合制占点,围绕资源调度形成攻防循环"
);
assert_eq!(
normalize_refined_text("核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。")
.expect("multi-line plain text should pass"),
"核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。"
);
}
}
@@ -36,6 +36,20 @@ pub fn map_llm_error(error: LlmError) -> AppError {
AppError::from_status(status).with_message(message)
}
pub(crate) fn is_retryable_llm_error(error: &LlmError) -> bool {
match error {
LlmError::Timeout { .. } | LlmError::Connectivity { .. } | LlmError::Transport(_) => true,
LlmError::Upstream { status_code, .. } => {
matches!(*status_code, 408 | 429 | 500..=599)
}
LlmError::InvalidConfig(_)
| LlmError::InvalidRequest(_)
| LlmError::StreamUnavailable
| LlmError::EmptyResponse
| LlmError::Deserialize(_) => false,
}
}
pub fn map_oss_error(error: OssError, provider: &'static str) -> AppError {
let status = oss_error_status(error.kind());
AppError::from_status(status).with_details(json!({
@@ -135,6 +149,29 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn llm_business_retries_only_transient_failures() {
assert!(is_retryable_llm_error(&LlmError::Timeout { attempts: 1 }));
assert!(is_retryable_llm_error(&LlmError::Upstream {
status_code: 429,
message: "rate limited".to_string(),
}));
assert!(is_retryable_llm_error(&LlmError::Upstream {
status_code: 503,
message: "unavailable".to_string(),
}));
assert!(!is_retryable_llm_error(&LlmError::InvalidConfig(
"missing key".to_string(),
)));
assert!(!is_retryable_llm_error(&LlmError::Upstream {
status_code: 401,
message: "unauthorized".to_string(),
}));
assert!(!is_retryable_llm_error(&LlmError::Deserialize(
"invalid response".to_string(),
)));
}
#[test]
fn map_wechat_provider_error_keeps_provider_boundary() {
let error = map_wechat_provider_error(WechatProviderError::MissingCode);
@@ -44,7 +44,7 @@ pub(crate) fn build_refine_play_setting_prompt(play_setting: &str) -> String {
8. 输出不得超过 {PROMPT_MAX_LEN} 个字符。
9. 不得出现解释、标题、Markdown、JSON、具体竞品名称或代码实现要求。
只返回一条可直接粘贴回输入框的中文 Prompt
只返回可直接使用的中文纯文本,可以分段或换行
"#
)
}
@@ -90,7 +90,7 @@ pub(crate) fn build_refine_art_style_prompt(art_style: &str) -> String {
8. 输出不得超过 {PROMPT_MAX_LEN} 个字符。
9. 不得出现解释、标题、Markdown、JSON、具体画师姓名或版权敏感IP引用。
只返回一条可直接粘贴回输入框的中文 Prompt
只返回可直接使用的中文纯文本,可以分段或换行
"#
)
}
@@ -228,7 +228,7 @@ green spill, visible grid lines, borders,"#,
}
pub(crate) fn build_spritesheet_prompt(
user_prompt: String,
user_prompt: &str,
screen_color: EditorScreenBackgroundColor,
genre: Option<GameGenre>,
) -> String {
@@ -320,8 +320,8 @@ pub(crate) fn build_spritesheet_prompt(
format!(
r#"你是一名经验丰富的游戏视觉设计师与游戏图标设计师(Game Icon Designer),擅长从零设计高质量、商业化、游戏开发就绪(Game Development-Ready)、可以导入游戏引擎或应用商店直接使用的游戏图标。
参考图1游戏类型为{genre_name},图标种类包括但不限于{icon_kinds}。
参考图1图标规范,背景必须是{background_color}平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不出现绿色描边、绿色底板、绿色投影或绿色反光;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。
按照以下素材的顺序从上到下从左到右依次生成并整理成一张,图标个数为2的幂次方,不要给出一模一样的图标
参考图1图标规范,背景必须是{background_color}平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便扣除背景;素材自身不出现与{background_color}相同或近似的描边、底板、投影、发光或反光,主体必须与背景色形成清晰对比;禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。
按照以下素材的顺序从上到下从左到右逐一生成并整理成一张,不要遗漏、补充或重复图标。每个图标之间必须保留清晰且一致的空白间距,禁止相邻图标的描边、底板、阴影、装饰或特效相互接触、连接或重叠
图标素材:{normalized_prompt}"#,
)
}
@@ -339,6 +339,8 @@ mod tests {
assert!(art_style_prompt.contains("用户当前输入:低多边形"));
assert!(play_setting_prompt.contains(format!("不得超过 {PROMPT_MAX_LEN} 个字符").as_str()));
assert!(art_style_prompt.contains(format!("不得超过 {PROMPT_MAX_LEN} 个字符").as_str()));
assert!(play_setting_prompt.contains("可直接使用的中文纯文本,可以分段或换行"));
assert!(art_style_prompt.contains("可直接使用的中文纯文本,可以分段或换行"));
}
#[test]
@@ -785,6 +785,18 @@ describe('ImageCanvasEditorModel', () => {
).toBeUndefined();
});
it('migrates restored legacy ui specs to the icon spec flow', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-legacy-ui-spec',
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'ui',
})?.specType,
).toBe('icon');
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
@@ -575,9 +575,7 @@ export function hydrateCanvasGenerationDialog(
: true,
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
specType: isSpecGenerationType(snapshot.specType)
? snapshot.specType
: undefined,
specType: normalizeSpecGenerationType(snapshot.specType),
specValues: hydrateSpecFormValues(snapshot.specValues),
specReference: hydrateCharacterReference(
snapshot.specReference,
@@ -1250,12 +1248,16 @@ function isGenerationStatus(
function isSpecGenerationType(
value: unknown,
): value is NonNullable<CanvasGenerationDialogState['specType']> {
return (
value === 'character' ||
value === 'ui' ||
value === 'icon' ||
value === 'custom'
);
return value === 'character' || value === 'icon' || value === 'custom';
}
function normalizeSpecGenerationType(
value: unknown,
): CanvasGenerationDialogState['specType'] {
if (value === 'ui') {
return 'icon';
}
return isSpecGenerationType(value) ? value : undefined;
}
function hydratePublicationWorkflowId(
@@ -353,11 +353,13 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
'玩法设定',
) as HTMLTextAreaElement;
const artStyle = screen.getByLabelText('美术风格') as HTMLTextAreaElement;
expect(playSetting.maxLength).toBe(200);
expect(artStyle.maxLength).toBe(200);
expect(playSetting.hasAttribute('maxlength')).toBe(false);
expect(artStyle.hasAttribute('maxlength')).toBe(false);
fireEvent.change(playSetting, { target: { value: overLimit } });
expect(Array.from(playSetting.value)).toHaveLength(200);
fireEvent.change(playSetting, { target: { value: '🎮'.repeat(201) } });
expect(Array.from(playSetting.value)).toHaveLength(200);
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
@@ -451,6 +453,36 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe(false);
});
it('ignores an optimization result after the active dialog changes', async () => {
const deferred = createDeferred<string>();
const updateSpecFormValue = vi.fn();
iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise);
const { rerender } = render(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-a', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
rerender(
<ImageCanvasSpecGenerationPanelView
dialog={createSpecDialog({ id: 'dialog-b', specType: 'icon' })}
style={{ left: 10, top: 20 }}
onUpdateSpecFormValue={updateSpecFormValue}
onRequestUpload={vi.fn()}
onSubmit={vi.fn()}
/>,
);
await act(async () => deferred.resolve('不应写入新对话框'));
expect(updateSpecFormValue).not.toHaveBeenCalled();
expect(screen.queryByRole('alert')).toBeNull();
});
it('keeps one undo snapshot per icon spec field and replaces it after a later successful optimization', async () => {
iconSpecClientMocks.refineGamePlay
.mockResolvedValueOnce('第一次优化')
@@ -6,6 +6,7 @@ import {
type RefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from 'react';
@@ -143,7 +144,16 @@ export function ImageCanvasSpecGenerationPanelView({
const [optimizationError, setOptimizationError] = useState<string | null>(
null,
);
const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`;
const activeOptimizationDialogKeyRef = useRef(optimizationDialogKey);
const optimizationRequestVersionsRef = useRef({
playSetting: 0,
artStyle: 0,
});
activeOptimizationDialogKeyRef.current = optimizationDialogKey;
useEffect(() => {
optimizationRequestVersionsRef.current.playSetting += 1;
optimizationRequestVersionsRef.current.artStyle += 1;
setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE);
setOptimizationError(null);
@@ -185,23 +195,39 @@ export function ImageCanvasSpecGenerationPanelView({
const setOptimization = isPlaySetting
? setPlaySettingOptimization
: setArtStyleOptimization;
const requestVersion = optimizationRequestVersionsRef.current[field] + 1;
optimizationRequestVersionsRef.current[field] = requestVersion;
const requestDialogKey = optimizationDialogKey;
setOptimization((current) => ({ ...current, optimizing: true }));
setOptimizationError(null);
try {
const refined = isPlaySetting
? await refineEditorIconSpecPlaySetting(value)
: await refineEditorIconSpecArtStyle(value);
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
optimizationRequestVersionsRef.current[field] !== requestVersion
) {
return;
}
setOptimization((current) => ({ ...current, optimizing: false }));
setOptimizationError(
const fallbackMessage = isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败';
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: isPlaySetting
? '优化玩法设定失败'
: '优化美术风格失败',
);
: fallbackMessage;
setOptimizationError(errorMessage);
}
};
@@ -389,7 +415,8 @@ export function ImageCanvasSpecGenerationPanelView({
}}
/>
</label>
) : dialog.specType === 'custom' ? (
) : null}
{!isUiDesignDialog && dialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
@@ -411,7 +438,8 @@ export function ImageCanvasSpecGenerationPanelView({
}
/>
</label>
) : isIconSpec ? (
) : null}
{!isUiDesignDialog && isIconSpec ? (
<>
{(
[
@@ -448,7 +476,6 @@ export function ImageCanvasSpecGenerationPanelView({
variant="textarea"
aria-label={item.title}
value={value}
maxLength={EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
placeholder={item.placeholder}
disabled={isGenerating || item.optimization.optimizing}
size="sm"
@@ -504,7 +531,8 @@ export function ImageCanvasSpecGenerationPanelView({
);
})}
</>
) : (
) : null}
{!isUiDesignDialog && dialog.specType !== 'custom' && !isIconSpec ? (
<>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
@@ -612,7 +640,7 @@ export function ImageCanvasSpecGenerationPanelView({
</>
) : null}
</>
)}
) : null}
</div>
{dialog.status === 'failed' ? (
<PlatformStatusMessage
@@ -310,11 +310,13 @@ type GenerationSubmissionWorkflowOptions = {
onGenerationWarning?: (message: string) => void;
};
async function normalizeImageGenerationReferenceImages(
input: Parameters<typeof generateEditorImage>[0],
async function normalizeGenerationReferenceImages<
T extends { referenceImageSrcs?: string[] },
>(
input: T,
references: CharacterReferenceImage[],
projectId?: string | null,
) {
): Promise<T> {
if (!input.referenceImageSrcs?.length) {
return input;
}
@@ -335,30 +337,6 @@ async function normalizeImageGenerationReferenceImages(
};
}
async function normalizeIconSpecGenerationReferenceImages(
input: Parameters<typeof generateEditorIconSpec>[0],
references: CharacterReferenceImage[],
projectId?: string | null,
) {
if (!input.referenceImageSrcs?.length) {
return input;
}
return {
...input,
referenceImageSrcs: await Promise.all(
input.referenceImageSrcs.map((referenceImageSrc, index) =>
resolveEditorGenerationMediaReference(
references[index]
? { ...references[index], src: referenceImageSrc }
: { src: referenceImageSrc },
'image',
projectId,
),
),
),
};
}
function resolveImageGenerationDialogReferences(
dialog: GenerateDialogState,
): CharacterReferenceImage[] {
@@ -1941,12 +1919,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.result.title,
);
} else if (submissionPlan.kind === 'icon-spec') {
const iconSpecInput =
await normalizeIconSpecGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const iconSpecInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await runEditorGenerationWithWalletRefresh(
@@ -2118,12 +2095,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
canvasDialog?.id,
);
} else {
const imageGenerationInput =
await normalizeImageGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const imageGenerationInput = await normalizeGenerationReferenceImages(
submissionPlan.input,
resolveImageGenerationDialogReferences(dialog),
projectId,
);
const resultTitle =
submissionPlan.result.title ??
`生成图片 ${layerCounterRef.current + 1}`;
@@ -24,9 +24,9 @@ import {
loadEditorGenerationPricing,
loadEditorProject,
loadOrCreateRecentEditorProject,
removeEditorImageBackground,
refineEditorIconSpecArtStyle,
refineEditorIconSpecPlaySetting,
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
splitEditorIconSpritesheet,
@@ -1356,6 +1356,9 @@ describe('editorProjectClient', () => {
expect(body).not.toHaveProperty('prompt');
expect(body).not.toHaveProperty('kind');
expect(body).not.toHaveProperty('assetKind');
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
timeoutMs: 1_200_000,
});
});
it('passes publication material generation kind and size to the backend BFF', async () => {
@@ -1020,7 +1020,6 @@ export async function generateEditorIconSpec(
'生成图标规范失败',
{
timeoutMs: 1_200_000,
retry: EDITOR_REQUEST_RETRY_OPTIONS,
},
);
}