diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index b60556a1f..9adb32be8 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -40,7 +40,7 @@ - 生成视频:`你希望生成什么视频?` 8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框;该完整文本按 Unicode 字符限制为 `200`,输入时按 code point 截断,不能用 UTF-16 `maxLength` 误截 emoji。 9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。 -10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`,手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本或 Markdown / 结构化内容时,后续有界重试必须携带上次无效输出和对应修正要求,不能原样重放同一请求。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。 +10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本、Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 CSS。 11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;后端固定图片参数。HTTP handler 先调用可复用的图片请求预检,完成参考图稳定性、owner 授权、Provider 配置和运行时定价校验;全部通过后才调用文本 LLM 补齐 `ExtraParam` 和最终 prompt,再把完整图片请求交给既有 `editor_image_generation` inline / queue 分流。不得为图标规范新增独立外部任务类型;最终 worker 仍按执行时事实重新校验,避免排队期间状态变化产生 TOCTOU。 12. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。 13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 81db42fe7..d01147a24 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -16,7 +16,7 @@ - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 - 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 -- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。 +- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。后端拆分授权只信任该项目资源的 `assetKind`,图层 `assetKindOverride` 仅用于展示,不能把其它资源提升为可拆分图集。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 ## 面板结构 @@ -41,7 +41,7 @@ - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 - inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:去除空白项后必须保留 `1..100` 条,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。 -- worker 解析主 `referenceImageSrc` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:资源 ID / 素材 ID 走主键,对象键按规范化 `image_src="/"` 索引定位单条资源或素材,并通过 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 +- worker 解析主 `referenceImageSrc` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:资源 ID / 素材 ID 走主键,对象键按规范化 `image_src="/"` 索引定位单条资源或素材,并通过 `asset_object(bucket, object_key)` 复合索引校验对象 owner。同一 ID 若同时命中项目资源和素材必须按协议歧义拒绝,不得静默偏向任一表。procedure 复用既有 `EditorProjectResourceSnapshot` 或 `EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"` 与 `genre` 都由图标图集业务代码从返回行校验和提取。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`。 - 请求字段: - `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。 - `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index c3ea39b72..3ae5ee1e3 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -722,7 +722,7 @@ impl EditorAgentTool for EditImageTool { impl EditorAgentTool for GenerateIconSpritesheetTool { fn validate_args(&self, args: &Value) -> Result { let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?; - GenerateIconSpritesheetTool::validate_args(self, &args) + let args = GenerateIconSpritesheetTool::normalize_args(self, args) .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; serialize_normalized_args(Self::NAME, &args) } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ca71175bb..004b30629 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -16111,7 +16111,7 @@ mod tests { } #[test] - fn editor_icon_spritesheet_slice_uses_authoritative_layer_override() { + fn editor_icon_spritesheet_slice_uses_authoritative_resource_kind() { let layers = json!([ { "layerId": "layer-default", @@ -16146,8 +16146,8 @@ mod tests { "resource-image", Some("character"), ) - .expect("layer override should win"), - Some("icon-spritesheet") + .expect("layer override must not promote the resource"), + Some("character") ); assert_eq!( resolve_editor_icon_spritesheet_source_layer_asset_kind( @@ -16156,8 +16156,8 @@ mod tests { "resource-atlas", Some("icon-spritesheet"), ) - .expect("blocking override should win"), - Some("character") + .expect("layer override must not demote the resource"), + Some("icon-spritesheet") ); assert!( resolve_editor_icon_spritesheet_source_layer_asset_kind( diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 63112b8e9..fe55350c1 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -1832,7 +1832,8 @@ pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>( ); } match source_layer.get("assetKindOverride") { - Some(Value::String(value)) => Ok(Some(value.trim())), + // 图层 override 只控制画布展示;手工拆分授权必须以服务端资源类型为准。 + Some(Value::String(_)) => Ok(resource_asset_kind), Some(Value::Null) | None => Ok(resource_asset_kind), Some(_) => Err( AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ 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 b5a0b4581..abcb8213d 100644 --- a/server-rs/crates/api-server/src/llm/icon_specs.rs +++ b/server-rs/crates/api-server/src/llm/icon_specs.rs @@ -101,6 +101,7 @@ enum RefinedTextError { Empty, TooLong, InvalidFormat, + Incomplete, } impl RefinedTextError { @@ -109,6 +110,7 @@ impl RefinedTextError { Self::Empty => "LLM 未返回可用文本".to_string(), Self::TooLong => format!("LLM 返回文本超过 {PROMPT_MAX_LEN} 个字符"), Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(), + Self::Incomplete => "LLM 返回内容未完整结束".to_string(), } } @@ -122,10 +124,26 @@ impl RefinedTextError { "上次输出包含 Markdown、结构化数据或解释性前缀。请只输出可直接使用的纯文本正文。" .to_string() } + Self::Incomplete => { + "上次输出因长度、内容过滤或上游中止而未完整结束。请重新输出完整的纯文本正文。" + .to_string() + } } } } +fn normalize_refined_text_response( + value: &str, + finish_reason: Option<&str>, +) -> Result { + if finish_reason.is_some_and(|reason| { + platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason) + }) { + return Err(RefinedTextError::Incomplete); + } + normalize_refined_text(value) +} + fn normalize_refined_text(value: &str) -> Result { let value = value.trim(); if value.is_empty() { @@ -170,7 +188,10 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result match normalize_refined_text(response.text.as_str()) { + Ok(response) => match normalize_refined_text_response( + response.text.as_str(), + response.finish_reason.as_deref(), + ) { Ok(text) => return Ok(text), Err(error) if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS => { return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( @@ -286,6 +307,19 @@ mod tests { ); } + #[test] + fn refined_text_rejects_incomplete_provider_completion() { + assert_eq!( + normalize_refined_text_response("看似完整但实际被截断", Some("length")).unwrap_err(), + RefinedTextError::Incomplete + ); + assert_eq!( + normalize_refined_text_response("完整结果", Some("stop")) + .expect("stop completion should pass"), + "完整结果" + ); + } + #[test] fn refine_request_bounds_provider_output() { let request = build_refine_text_llm_request("优化玩法".to_string()); diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 55f451d15..9d867fbbd 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -215,6 +215,20 @@ impl GenerateIconSpritesheetTool { pub const MAX_ICON_DESCRIPTIONS_TOTAL_CHARS: usize = 2_000; pub const MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES: usize = 6 * 1024; + pub fn normalize_args( + &self, + mut args: GenerateIconSpritesheetToolArgs, + ) -> Result { + args.icon_descriptions = args + .icon_descriptions + .into_iter() + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()) + .collect(); + self.validate_args(&args)?; + Ok(args) + } + pub fn validate_args( &self, args: &GenerateIconSpritesheetToolArgs, @@ -427,4 +441,20 @@ mod tests { Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes(_)) )); } + + #[test] + fn normalizes_icon_descriptions_before_propagation() { + let padded = format!("{}背包{}", " ".repeat(8_000), "\n".repeat(8_000)); + let (tool, args) = tool_and_args(vec![" ".to_string(), padded]); + + let normalized = tool + .normalize_args(args) + .expect("trimmed description should satisfy the contract"); + + assert_eq!(normalized.icon_descriptions, vec!["背包"]); + assert_eq!( + serde_json::to_value(&normalized).expect("normalized args should serialize")["icon_descriptions"], + json!(["背包"]) + ); + } } diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index f43d972a9..168df6b62 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -1096,9 +1096,12 @@ pub(crate) fn map_editor_reference_procedure_result( (Some(resource), None) => map_editor_project_resource_snapshot(resource) .map(EditorReferenceRecord::ProjectResource), (None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset), - _ => Err(SpacetimeClientError::missing_snapshot( + (None, None) => Err(SpacetimeClientError::missing_snapshot( "编辑器引用资源或素材快照", )), + (Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some( + "SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(), + ))), } } diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index c7c3a46b8..336c44049 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -1095,9 +1095,12 @@ pub(crate) fn map_editor_reference_procedure_result( (Some(resource), None) => map_editor_project_resource_snapshot(resource) .map(EditorReferenceRecord::ProjectResource), (None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset), - _ => Err(SpacetimeClientError::missing_snapshot( + (None, None) => Err(SpacetimeClientError::missing_snapshot( "编辑器引用资源或素材快照", )), + (Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some( + "SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(), + ))), } } diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 7fd6d11da..f931c3303 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -6328,23 +6328,24 @@ fn resolve_editor_reference( if reference_id.is_empty() { return Err("编辑器引用的 reference_id 不能为空".to_string()); } - if let Some(resource) = ctx + let resource = ctx .db .editor_project_resource() .resource_id() - .find(&reference_id.to_string()) - { - return resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource); - } - if let Some(asset) = ctx + .find(&reference_id.to_string()); + let asset = ctx .db .editor_asset() .asset_id() - .find(&reference_id.to_string()) - { - return resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset); + .find(&reference_id.to_string()); + match (resource, asset) { + (Some(_), Some(_)) => Err("编辑器引用 ID 同时命中项目资源与素材,无法唯一解析".to_string()), + (Some(resource), None) => { + resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource) + } + (None, Some(asset)) => resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset), + (None, None) => Err("编辑器引用不存在".to_string()), } - Err("编辑器引用不存在".to_string()) } fn resolve_editor_reference_resource( diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index c6d684ba2..71eba950d 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -697,6 +697,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => { }); }); + it('returns an icon spritesheet error when the spec reference is not registered', () => { + const plan = buildIconSpritesheetGenerationSubmissionPlan({ + mode: 'icon', + prompt: '返回按钮', + status: 'idle', + iconSpecReference: { + id: 'local-icon-spec', + label: '本地图标规范', + src: 'data:image/png;base64,spec', + resourceId: 'local-resource-icon-spec', + }, + }); + + expect(plan).toEqual({ + ok: false, + errorMessage: '参考图尚未登记为项目资源或素材,请重新选择或上传后再试', + }); + }); + it('returns an icon spritesheet error when descriptions are empty', () => { const plan = buildIconSpritesheetGenerationSubmissionPlan({ mode: 'icon', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 840036a80..70dc676d6 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -686,6 +686,18 @@ export function buildIconSpritesheetGenerationSubmissionPlan( errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`, }; } + let referenceId: string; + try { + referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference); + } catch (error) { + return { + ok: false, + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '图标规范尚未完成资源登记,请重新选择或上传后再试', + }; + } const rememberImageModel = normalizeEditorImageModel(dialog.imageModel); const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR; @@ -694,7 +706,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan( ok: true, iconDescriptions, input: { - referenceId: resolveRegisteredEditorReferenceId(dialog.iconSpecReference), + referenceId, ...(dialog.generationReferences?.length ? { referenceImageSrcs: dialog.generationReferences.map((reference) => diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index cd3c46568..0b52a7bed 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -532,6 +532,26 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ).toBe('手动修改玩法'); }); + it('restores the exact pre-optimization value including surrounding whitespace', async () => { + iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法'); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('优化后的玩法'), + ); + expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith( + '回合制占点', + ); + + fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe(' 回合制占点 '); + }); + it('clears the field undo snapshot after a manual edit', async () => { iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化'); render(); diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 65f9e7f03..014b23742 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -206,7 +206,8 @@ export function ImageCanvasSpecGenerationPanelView({ const key: keyof SpecFormValues = isPlaySetting ? 'playSetting' : 'artStyle'; - const value = dialog.specValues?.[key]?.trim() ?? ''; + const undoValue = dialog.specValues?.[key] ?? ''; + const value = undoValue.trim(); if (!value) { return; } @@ -229,7 +230,7 @@ export function ImageCanvasSpecGenerationPanelView({ return; } onUpdateSpecFormValue(key, refined); - setOptimization({ optimizing: false, undoValue: value }); + setOptimization({ optimizing: false, undoValue }); } catch (error) { if ( activeOptimizationDialogKeyRef.current !== requestDialogKey || @@ -472,18 +473,22 @@ export function ImageCanvasSpecGenerationPanelView({ ] as const ).map((item) => { const value = dialog.specValues?.[item.key] ?? ''; + const fieldId = `image-canvas-icon-spec-${dialog.id ?? 'active'}-${item.field}`; return ( - + ); })} diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 8a1933af3..7dd353335 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -83,7 +83,6 @@ import { buildIconSpritesheetGenerationSubmissionPlan, buildImageGenerationSubmissionPlan, resolveGenerationAssetLabel, - resolveRegisteredEditorReferenceId, } from './ImageCanvasGenerationSubmissionModel'; import type { UiAssetExtractionMark, @@ -1341,11 +1340,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ if (!canvasDialog) { return; } - const iconSpecReference = dialog.iconSpecReference; - if (!iconSpecReference) { - return; - } - setSubmittingIconDialog({ ...canvasDialog, iconDescriptions: submissionPlan.iconDescriptions, @@ -1357,7 +1351,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ try { const canvasCompletionPlaceholder = getGeneratingDialogPlaceholder(dialog); - const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference); const referenceImageSrcs = await Promise.all( (dialog.generationReferences ?? []).map((reference) => resolveEditorGenerationMediaReference( @@ -1370,7 +1363,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const generated = await runEditorGenerationWithWalletRefresh( generateEditorIconSpritesheet({ ...submissionPlan.input, - referenceId, ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), projectId, generationInputs: submissionPlan.generationInputs, diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 4627413df..49807c463 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -11,11 +11,11 @@ import { deleteEditorAssetFolder, deleteEditorProject, editEditorImage, + EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, EDITOR_ICON_DESCRIPTION_MAX_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH, - EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, @@ -1596,6 +1596,31 @@ describe('editorProjectClient', () => { ); }); + it('normalizes icon spec fields and rejects blank values before requesting', async () => { + await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow( + '玩法设定不能为空', + ); + await expect( + generateEditorIconSpec({ + playSetting: '回合制', + artStyle: ' ', + }), + ).rejects.toThrow('美术风格不能为空'); + expect(requestJsonMock).not.toHaveBeenCalled(); + + requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' }); + await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe( + '优化玩法', + ); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/llm/icon-specs/refine-game-play', + expect.objectContaining({ + body: JSON.stringify({ playSetting: '原玩法' }), + }), + '优化玩法设定失败', + ); + }); + it('rejects overlong icon spec generation fields before dispatch', async () => { await expect( generateEditorIconSpec({ diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 18dc90033..82c4ba2d2 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -76,12 +76,16 @@ function assertStableEditorMediaReferences( } function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) { - if (Array.from(value).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`${fieldLabel}不能为空`); + } + if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) { throw new Error( `${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`, ); } - return value; + return normalized; } function resolveEditorProviderReferenceLimit(model: string | null | undefined) {