修复图标规范优化细节

空响应纳入有界重试,并为优化请求设置输出 token 上限。
修复题材提示词括号并补充回归测试。
将优化请求身份更新移入已提交 effect,避免并发渲染污染。
移除冗余类型注释并同步编辑器文档。
This commit is contained in:
2026-08-06 11:33:26 +08:00
parent 3550f046e4
commit e6f0e6248e
7 changed files with 58 additions and 13 deletions
@@ -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。
@@ -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号红色圈选框里的内容做以下修改:`
@@ -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<String,
})?;
let retry_backoff_ms = configured_llm_client.config().retry_backoff_ms();
let llm_client = configured_llm_client.clone().with_max_retries(0);
let request = LlmRunRequest::new(vec![LlmMessage::user(prompt)])
.with_model(EDITOR_AGENT_GPT5_MODEL)
.with_openai_chat();
let request = build_refine_text_llm_request(prompt);
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
@@ -183,6 +182,13 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String,
unreachable!("icon spec refine retry loop always returns")
}
fn build_refine_text_llm_request(prompt: String) -> 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);
}
}
@@ -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(),
)));
@@ -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("题材: 【现代军事】"));
}
}
@@ -152,7 +152,6 @@ export type EditorAssetFolder = {
persisted: boolean;
};
// 这里指规范的类型
export type SpecGenerationType = 'character' | 'icon' | 'custom';
export type SpecFormValues = {
@@ -155,19 +155,26 @@ export function ImageCanvasSpecGenerationPanelView({
null,
);
const optimizationDialogKey = `${dialog.id ?? 'active'}:${dialog.mode}:${dialog.specType ?? ''}`;
const activeOptimizationDialogKeyRef = useRef(optimizationDialogKey);
const activeOptimizationDialogKeyRef = useRef<string | null>(
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 =