修复图标生成审查中的轻量问题
图标规范输出校验失败时携带无效响应和修正指令重试。 图标图集队列在定价和持久化前归一化描述数量。 手工编辑规范字段后清除失效撤销快照。 收口项目生成对话框的重复复位逻辑并补充测试和文档。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user