diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e9d95b011..6dcc1942d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -116,8 +116,8 @@ - `GET /api/editor/projects/recent`:读取当前用户最近编辑的图片画布工程,没有则返回 `project: null`。 - 图标规范专用链路(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` 按 UTF-16 码元计数,与该业务口径不一致,因此图标规范文本域不设置 `maxLength`,只通过按 Unicode 字符截断的 `onChange` 和提交校验限制输入;非法恢复态禁用优化与生成。editor client 在优化请求、优化响应和最终生成提交前再次拒绝超长值;api-server 对两个优化入口和最终图标规范生成入口都执行同一上限校验。LLM 优化结果必须是无标题、解释、Markdown 或 JSON 的可直接使用纯文本,允许分段和换行;非法格式与空文本、超长文本一样作为非法模型输出重试,第三次仍非法返回 `502`。 +- `POST /api/editor/llm/icon-specs/refine-game-play` 与 `POST /api/editor/llm/icon-specs/refine-art-style`:分别接收 `{ playSetting }` / `{ artStyle }`,由 api-server 使用 VectorEngine LLM client 构造业务 prompt。两个字段各自维护处理中和单次撤销快照;一项处理中不锁另一项,但任一项处理中禁止提交生成。活动生成对象的请求身份只在 React effect 提交后更新,并在 effect cleanup 中失效,丢弃的并发 render 不得改变当前请求归属。 +- 图标规范的 `playSetting / artStyle` 单字段上限统一为 `200` 个 Unicode 字符。浏览器原生 `maxLength` 按 UTF-16 码元计数,与该业务口径不一致,因此图标规范文本域不设置 `maxLength`,只通过按 Unicode 字符截断的 `onChange` 和提交校验限制输入;非法恢复态禁用优化与生成。editor client 在优化请求、优化响应和最终生成提交前再次拒绝超长值;api-server 对两个优化入口和最终图标规范生成入口都执行同一上限校验。LLM 优化请求固定 `1024` 输出 token 上限,容纳推理开销同时限制最多三次调用的输出成本;平台层返回 `EmptyResponse` 时按瞬态空结果进入同一有界重试。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。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 385d256a2..c42d2993d 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 和请求代次;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。 +10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销、失败保留文本与旧撤销快照。优化请求必须绑定发起时的生成对象 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/server-rs/crates/api-server/src/llm/icon_specs.rs b/server-rs/crates/api-server/src/llm/icon_specs.rs index 9ab92221d..527bae129 100644 --- a/server-rs/crates/api-server/src/llm/icon_specs.rs +++ b/server-rs/crates/api-server/src/llm/icon_specs.rs @@ -22,6 +22,7 @@ use crate::{ }; const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3; +const ICON_SPEC_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,9 +153,7 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result Result LlmRunRequest { + LlmRunRequest::new(vec![LlmMessage::user(prompt)]) + .with_model(EDITOR_AGENT_GPT5_MODEL) + .with_max_output_tokens(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS) + .with_openai_chat() +} + 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 { @@ -247,4 +253,15 @@ mod tests { "核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。" ); } + + #[test] + fn refine_request_bounds_provider_output() { + let request = build_refine_text_llm_request("优化玩法".to_string()); + + assert_eq!( + request.max_output_tokens, + Some(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS) + ); + assert_eq!(request.api_kind, platform_llm::LlmApiKind::OpenAiChat); + } } diff --git a/server-rs/crates/api-server/src/platform_errors.rs b/server-rs/crates/api-server/src/platform_errors.rs index 205baac40..697e34852 100644 --- a/server-rs/crates/api-server/src/platform_errors.rs +++ b/server-rs/crates/api-server/src/platform_errors.rs @@ -38,14 +38,16 @@ pub fn map_llm_error(error: LlmError) -> AppError { pub(crate) fn is_retryable_llm_error(error: &LlmError) -> bool { match error { - LlmError::Timeout { .. } | LlmError::Connectivity { .. } | LlmError::Transport(_) => true, + LlmError::Timeout { .. } + | LlmError::Connectivity { .. } + | LlmError::Transport(_) + | LlmError::EmptyResponse => true, LlmError::Upstream { status_code, .. } => { matches!(*status_code, 408 | 429 | 500..=599) } LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) | LlmError::StreamUnavailable - | LlmError::EmptyResponse | LlmError::Deserialize(_) => false, } } @@ -160,6 +162,7 @@ mod tests { status_code: 503, message: "unavailable".to_string(), })); + assert!(is_retryable_llm_error(&LlmError::EmptyResponse)); assert!(!is_retryable_llm_error(&LlmError::InvalidConfig( "missing key".to_string(), ))); 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 3bc267b3c..fe2fc6675 100644 --- a/server-rs/crates/api-server/src/prompt/icon_spec.rs +++ b/server-rs/crates/api-server/src/prompt/icon_spec.rs @@ -162,7 +162,7 @@ pub(crate) fn get_gen_icon_spec_prompt( 玩法设定: {play_setting} 美术风格: {art_style} 游戏类型: {genre_name} -题材: {theme}】 +题材: 【{theme}】 {reference_image_prompt} 使用场景为【{use_case}】。图标设计需适配该平台的展示规范和缩略图尺寸。适合目标用户【{target_user}】玩家。 "#, @@ -354,4 +354,23 @@ mod tests { "偏好战术规划与中等强度对抗的策略游戏玩家" ); } + + #[test] + fn generated_icon_spec_prompt_balances_theme_delimiters() { + let prompt = get_gen_icon_spec_prompt( + &RequestParam { + play_setting: "回合制占点".to_string(), + art_style: "低多边形".to_string(), + }, + &ExtraParam { + genre: GameGenre::Strategy, + theme: "现代军事".to_string(), + use_case: "PC".to_string(), + target_user: "策略游戏玩家".to_string(), + }, + false, + ); + + assert!(prompt.contains("题材: 【现代军事】")); + } } diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index e4f2d7025..525616e3a 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -152,7 +152,6 @@ export type EditorAssetFolder = { persisted: boolean; }; -// 这里指规范的类型 export type SpecGenerationType = 'character' | 'icon' | 'custom'; export type SpecFormValues = { diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index cb27c8db0..389a9e3fa 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -155,19 +155,26 @@ export function ImageCanvasSpecGenerationPanelView({ null, ); const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`; - const activeOptimizationDialogKeyRef = useRef(optimizationDialogKey); + const activeOptimizationDialogKeyRef = useRef( + optimizationDialogKey, + ); const optimizationRequestVersionsRef = useRef({ playSetting: 0, artStyle: 0, }); - activeOptimizationDialogKeyRef.current = optimizationDialogKey; useEffect(() => { + activeOptimizationDialogKeyRef.current = optimizationDialogKey; optimizationRequestVersionsRef.current.playSetting += 1; optimizationRequestVersionsRef.current.artStyle += 1; setPlaySettingOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); setArtStyleOptimization(INITIAL_ICON_SPEC_OPTIMIZATION_STATE); setOptimizationError(null); - }, [dialog.id, dialog.mode, dialog.specType]); + return () => { + if (activeOptimizationDialogKeyRef.current === optimizationDialogKey) { + activeOptimizationDialogKeyRef.current = null; + } + }; + }, [optimizationDialogKey]); const isOptimizingIconSpec = playSettingOptimization.optimizing || artStyleOptimization.optimizing; const hasRequiredIconSpecValues =