diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 175e624c5..88a2b6ff3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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}`:读取指定工程及资源列表。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 5c0f77d54..1ed8dc520 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -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号红色圈选框里的内容做以下修改:`。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 0e8e91a7b..7495a8cbb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, ); diff --git a/server-rs/crates/api-server/src/editor_project_icon_specs.rs b/server-rs/crates/api-server/src/editor_project_icon_specs.rs index 0e3751422..7ef0fad6a 100644 --- a/server-rs/crates/api-server/src/editor_project_icon_specs.rs +++ b/server-rs/crates/api-server/src/editor_project_icon_specs.rs @@ -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::(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 { + let mut extra_param = serde_json::from_str::(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") + ); } } diff --git a/server-rs/crates/api-server/src/llm/icon_specs.rs b/server-rs/crates/api-server/src/llm/icon_specs.rs index 8b9e657df..f8591159d 100644 --- a/server-rs/crates/api-server/src/llm/icon_specs.rs +++ b/server-rs/crates/api-server/src/llm/icon_specs.rs @@ -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 { 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 {} }, - 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玩家通过资源调度形成攻防转换。" + ); + } } diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 6da6f87c3..205baac40 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -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); diff --git a/server-rs/crates/api-server/src/prompt/icon_spec.rs b/server-rs/crates/api-server/src/prompt/icon_spec.rs index 2be23d242..99799ee95 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -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, ) -> 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] diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 43ed3ea5b..5a2330f4f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -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', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 8225f6ccb..e9ad9bc47 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -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 { - 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( diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index c287ab190..15407d0b3 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -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(); + const updateSpecFormValue = vi.fn(); + iconSpecClientMocks.refineGamePlay.mockReturnValueOnce(deferred.promise); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + rerender( + , + ); + 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('第一次优化') diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 54172748a..1fae87bc9 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -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( 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({ }} /> - ) : dialog.specType === 'custom' ? ( + ) : null} + {!isUiDesignDialog && dialog.specType === 'custom' ? ( - ) : 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 ? ( <>