修复图标生成审查中的轻量问题

图标规范输出校验失败时携带无效响应和修正指令重试。
图标图集队列在定价和持久化前归一化描述数量。
手工编辑规范字段后清除失效撤销快照。
收口项目生成对话框的重复复位逻辑并补充测试和文档。
This commit is contained in:
2026-08-06 16:19:48 +08:00
parent 3fdbe81bfe
commit 0b3475ef98
8 changed files with 136 additions and 65 deletions
@@ -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号红色圈选框里的内容做以下修改:`
@@ -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="/<objectKey>"` 索引定位单条资源或素材,并通过 `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。
@@ -14541,6 +14541,19 @@ mod tests {
.map(|index| format!("图标{index}"))
.collect::<Vec<_>>();
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]
@@ -1099,6 +1099,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
) -> Result<ExternalGenerationJobRecord, AppError> {
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(),
@@ -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<String, RefinedTextError> {
@@ -153,7 +166,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 = build_refine_text_llm_request(prompt);
let mut request = build_refine_text_llm_request(prompt.clone());
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
@@ -168,7 +181,10 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String,
}),
));
}
Err(_) => {}
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<String,
}
fn build_refine_text_llm_request(prompt: String) -> 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<LlmMessage>) -> 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("只输出可直接使用的纯文本正文")
);
}
}
@@ -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(<IconSpecHarness />);
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,
@@ -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);
}}
/>
<div className="flex items-center gap-1">
<PlatformActionButton
@@ -1772,6 +1772,26 @@ export function useImageCanvasGenerationSubmissionWorkflow({
onWalletBalanceMayHaveChanged,
]);
const resetProjectBackedGenerationDialog = useCallback(
(canvasDialog: CanvasGenerationDialogState | 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,