修复图标规范优化细节
空响应纳入有界重试,并为优化请求设置输出 token 上限。 修复题材提示词括号并补充回归测试。 将优化请求身份更新移入已提交 effect,避免并发渲染污染。 移除冗余类型注释并同步编辑器文档。
This commit is contained in:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user