From 0b3475ef985498c6ba494194d17ea97b562c9d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 6 Aug 2026 16:19:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E6=A0=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=AE=A1=E6=9F=A5=E4=B8=AD=E7=9A=84=E8=BD=BB=E9=87=8F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标规范输出校验失败时携带无效响应和修正指令重试。 图标图集队列在定价和持久化前归一化描述数量。 手工编辑规范字段后清除失效撤销快照。 收口项目生成对话框的重复复位逻辑并补充测试和文档。 --- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 1 + .../crates/api-server/src/editor_project.rs | 13 ++++ .../api-server/src/editor_project_icon.rs | 1 + .../crates/api-server/src/llm/icon_specs.rs | 64 ++++++++++++++- ...mageCanvasSpecGenerationPanelView.test.tsx | 30 +++++-- .../ImageCanvasSpecGenerationPanelView.tsx | 12 ++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 78 ++++++------------- 8 files changed, 136 insertions(+), 65 deletions(-) diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index c42d2993d..2e97546a0 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"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销、失败保留文本与旧撤销快照。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。 +10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本或 Markdown / 结构化内容时,后续有界重试必须携带上次无效输出和对应修正要求,不能原样重放同一请求。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 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 7653fde75..7254461d8 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -40,6 +40,7 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 +- inline 与持久队列入口共用同一份 `iconDescriptions` 归一化:去除空白项后必须保留 `1..100` 条;队列入口必须在定价和任务持久化前同步拒绝可预测的数量错误,不能把无效任务留给 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`。 - 请求字段: - `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 252be1560..3c07e2a2b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -14541,6 +14541,19 @@ mod tests { .map(|index| format!("图标{index}")) .collect::>(); assert!(normalize_icon_descriptions(too_many).is_err()); + + let source = include_str!("editor_project_icon.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner", + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + &[ + "payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;", + "resolve_editor_icon_spec_reference(", + "resolve_editor_icon_spritesheet_price(", + "enqueue_editor_generation_job_for_caller(", + ], + ); } #[test] 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 83c51e804..11cc3e2ee 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -1099,6 +1099,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( ) -> Result { payload.generation_inputs = sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); + payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; resolve_editor_icon_spec_reference( state, caller.owner_user_id.as_str(), 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 527bae129..b5a0b4581 100644 --- a/server-rs/crates/api-server/src/llm/icon_specs.rs +++ b/server-rs/crates/api-server/src/llm/icon_specs.rs @@ -111,6 +111,19 @@ impl RefinedTextError { Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(), } } + + fn repair_instruction(self) -> String { + match self { + Self::Empty => "上次输出为空。请重新输出一条可直接使用的纯文本。".to_string(), + Self::TooLong => format!( + "上次输出超过 {PROMPT_MAX_LEN} 个字符。请压缩到限制内,只保留可直接使用的正文。" + ), + Self::InvalidFormat => { + "上次输出包含 Markdown、结构化数据或解释性前缀。请只输出可直接使用的纯文本正文。" + .to_string() + } + } + } } fn normalize_refined_text(value: &str) -> Result { @@ -153,7 +166,7 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result Result {} + Err(error) => { + request = + build_refine_text_llm_repair_request(prompt.clone(), response.text, error); + } }, Err(error) if is_retryable_llm_error(&error) => { if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS { @@ -183,7 +199,23 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result LlmRunRequest { - LlmRunRequest::new(vec![LlmMessage::user(prompt)]) + build_refine_text_llm_request_with_messages(vec![LlmMessage::user(prompt)]) +} + +fn build_refine_text_llm_repair_request( + prompt: String, + invalid_response: String, + error: RefinedTextError, +) -> LlmRunRequest { + build_refine_text_llm_request_with_messages(vec![ + LlmMessage::user(prompt), + LlmMessage::assistant(invalid_response), + LlmMessage::user(error.repair_instruction()), + ]) +} + +fn build_refine_text_llm_request_with_messages(messages: Vec) -> LlmRunRequest { + LlmRunRequest::new(messages) .with_model(EDITOR_AGENT_GPT5_MODEL) .with_max_output_tokens(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS) .with_openai_chat() @@ -264,4 +296,30 @@ mod tests { ); assert_eq!(request.api_kind, platform_llm::LlmApiKind::OpenAiChat); } + + #[test] + fn refine_repair_request_preserves_invalid_output_and_adds_targeted_correction() { + let request = build_refine_text_llm_repair_request( + "优化玩法".to_string(), + "```markdown\n说明:优化结果\n```".to_string(), + RefinedTextError::InvalidFormat, + ); + + assert_eq!(request.messages.len(), 3); + assert_eq!(request.messages[0].role, platform_llm::LlmMessageRole::User); + assert_eq!( + request.messages[1].role, + platform_llm::LlmMessageRole::Assistant + ); + assert_eq!( + request.messages[1].content, + "```markdown\n说明:优化结果\n```" + ); + assert_eq!(request.messages[2].role, platform_llm::LlmMessageRole::User); + assert!( + request.messages[2] + .content + .contains("只输出可直接使用的纯文本正文") + ); + } } diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx index 9017fb9cf..6e7fa032a 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx @@ -520,7 +520,30 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ).toBe('手动修改玩法'); }); - it('leaves text and the existing undo snapshot unchanged when optimization fails', async () => { + it('clears the field undo snapshot after a manual edit', async () => { + iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化'); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); + await waitFor(() => + expect( + (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, + ).toBe('成功优化'), + ); + expect( + screen.getByRole('button', { name: '撤销玩法设定优化' }), + ).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('玩法设定'), { + target: { value: '手动修改玩法' }, + }); + + expect( + screen.queryByRole('button', { name: '撤销玩法设定优化' }), + ).toBeNull(); + }); + + it('leaves text and the existing valid undo snapshot unchanged when optimization fails', async () => { iconSpecClientMocks.refineGamePlay .mockResolvedValueOnce('成功优化') .mockRejectedValueOnce(new Error('服务暂不可用')); @@ -532,9 +555,6 @@ describe('ImageCanvasSpecGenerationPanelView', () => { (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, ).toBe('成功优化'), ); - fireEvent.change(screen.getByLabelText('玩法设定'), { - target: { value: '失败前文本' }, - }); fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' })); await waitFor(() => @@ -542,7 +562,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => { ); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, - ).toBe('失败前文本'); + ).toBe('成功优化'); fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' })); expect( (screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value, diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index 389a9e3fa..2195d5613 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -489,12 +489,18 @@ export function ImageCanvasSpecGenerationPanelView({ placeholder={item.placeholder} disabled={isGenerating || item.optimization.optimizing} className="image-canvas-editor__generation-prompt image-canvas-editor__spec-textarea" - onChange={(event) => + onChange={(event) => { onUpdateSpecFormValue( item.key, limitIconSpecPrompt(event.target.value), - ) - } + ); + (item.field === 'playSetting' + ? setPlaySettingOptimization + : setArtStyleOptimization)( + INITIAL_ICON_SPEC_OPTIMIZATION_STATE, + ); + setOptimizationError(null); + }} />
{ + if (!canvasDialog || !projectId) { + return false; + } + updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => + currentDialog.generatedLayerId + ? currentDialog + : { + ...currentDialog, + status: 'idle', + composerOpen: true, + errorMessage: undefined, + }, + ); + return true; + }, + [projectId, updateCanvasGenerationDialogById], + ); + const submitImageGeneration = useCallback( async (dialog: GenerateDialogState) => { const normalizedPrompt = @@ -1985,20 +2005,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } return; } - // TODO refactor here - if (canvasDialog && projectId) { - updateCanvasGenerationDialogById( - canvasDialog.id, - (currentDialog) => - currentDialog.generatedLayerId - ? currentDialog - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - errorMessage: undefined, - }, - ); + if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addGeneratedResultLayer(generated, { @@ -2056,19 +2063,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } return; } - if (canvasDialog && projectId) { - updateCanvasGenerationDialogById( - canvasDialog.id, - (currentDialog) => - currentDialog.generatedLayerId - ? currentDialog - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - errorMessage: undefined, - }, - ); + if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addVideoResultLayer( @@ -2135,19 +2130,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } return; } - if (canvasDialog && projectId) { - updateCanvasGenerationDialogById( - canvasDialog.id, - (currentDialog) => - currentDialog.generatedLayerId - ? currentDialog - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - errorMessage: undefined, - }, - ); + if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addAudioResultLayer( @@ -2214,19 +2197,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } return; } - if (canvasDialog && projectId) { - updateCanvasGenerationDialogById( - canvasDialog.id, - (currentDialog) => - currentDialog.generatedLayerId - ? currentDialog - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - errorMessage: undefined, - }, - ); + if (resetProjectBackedGenerationDialog(canvasDialog)) { return; } addGeneratedResultLayer(generated, { @@ -2267,6 +2238,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ layers, quickEditSelectionState, rememberImageModel, + resetProjectBackedGenerationDialog, projectId, assetFolderId, applyProjectSnapshot,