修复图标规范评审问题
迁移旧版 ui 规范快照并隔离跨对话框优化结果。 关闭非幂等图标规范生成重试并统一参考图解析逻辑。 限制 LLM 重试范围并严格校验优化文本与补全参数。 修正图集键色、数量与素材间距提示约束并补充行为测试。 同步图片画布与生成面板技术文档。
This commit is contained in:
@@ -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` 份 PNG;api-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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user