修复图标图集审查中的边界问题

校验 LLM 完成原因并规范化 Agent 图标描述
统一前后端图标规范输入、错误返回和精确撤销行为
收紧图集拆分授权并拒绝引用 ID 歧义
修正快照映射诊断和表单标签可访问性
补充定向测试与编辑器设计文档
This commit is contained in:
2026-08-06 19:15:10 +08:00
parent 2b8ced9195
commit 2266d7fe89
17 changed files with 194 additions and 45 deletions
@@ -697,6 +697,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the spec reference is not registered', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
iconSpecReference: {
id: 'local-icon-spec',
label: '本地图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'local-resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: '参考图尚未登记为项目资源或素材,请重新选择或上传后再试',
});
});
it('returns an icon spritesheet error when descriptions are empty', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -686,6 +686,18 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
let referenceId: string;
try {
referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference);
} catch (error) {
return {
ok: false,
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '图标规范尚未完成资源登记,请重新选择或上传后再试',
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
@@ -694,7 +706,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
ok: true,
iconDescriptions,
input: {
referenceId: resolveRegisteredEditorReferenceId(dialog.iconSpecReference),
referenceId,
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
@@ -532,6 +532,26 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe('手动修改玩法');
});
it('restores the exact pre-optimization value including surrounding whitespace', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法');
render(<IconSpecHarness playSetting=" 回合制占点 " />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('优化后的玩法'),
);
expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith(
'回合制占点',
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe(' 回合制占点 ');
});
it('clears the field undo snapshot after a manual edit', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化');
render(<IconSpecHarness />);
@@ -206,7 +206,8 @@ export function ImageCanvasSpecGenerationPanelView({
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const value = dialog.specValues?.[key]?.trim() ?? '';
const undoValue = dialog.specValues?.[key] ?? '';
const value = undoValue.trim();
if (!value) {
return;
}
@@ -229,7 +230,7 @@ export function ImageCanvasSpecGenerationPanelView({
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
setOptimization({ optimizing: false, undoValue });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
@@ -472,18 +473,22 @@ export function ImageCanvasSpecGenerationPanelView({
] as const
).map((item) => {
const value = dialog.specValues?.[item.key] ?? '';
const fieldId = `image-canvas-icon-spec-${dialog.id ?? 'active'}-${item.field}`;
return (
<label
<div
key={item.field}
className="image-canvas-editor__field-block"
>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
<label htmlFor={fieldId}>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
</label>
<AutoGrowTextArea
id={fieldId}
aria-label={item.title}
value={value}
placeholder={item.placeholder}
@@ -562,7 +567,7 @@ export function ImageCanvasSpecGenerationPanelView({
{EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
</span>
</div>
</label>
</div>
);
})}
</>
@@ -83,7 +83,6 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
@@ -1341,11 +1340,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
if (!canvasDialog) {
return;
}
const iconSpecReference = dialog.iconSpecReference;
if (!iconSpecReference) {
return;
}
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
@@ -1357,7 +1351,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
@@ -1370,7 +1363,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
referenceId,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.generationInputs,
@@ -11,11 +11,11 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
@@ -1596,6 +1596,31 @@ describe('editorProjectClient', () => {
);
});
it('normalizes icon spec fields and rejects blank values before requesting', async () => {
await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow(
'玩法设定不能为空',
);
await expect(
generateEditorIconSpec({
playSetting: '回合制',
artStyle: ' ',
}),
).rejects.toThrow('美术风格不能为空');
expect(requestJsonMock).not.toHaveBeenCalled();
requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' });
await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe(
'优化玩法',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/llm/icon-specs/refine-game-play',
expect.objectContaining({
body: JSON.stringify({ playSetting: '原玩法' }),
}),
'优化玩法设定失败',
);
});
it('rejects overlong icon spec generation fields before dispatch', async () => {
await expect(
generateEditorIconSpec({
@@ -76,12 +76,16 @@ function assertStableEditorMediaReferences(
}
function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) {
if (Array.from(value).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
const normalized = value.trim();
if (!normalized) {
throw new Error(`${fieldLabel}不能为空`);
}
if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
throw new Error(
`${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`,
);
}
return value;
return normalized;
}
function resolveEditorProviderReferenceLimit(model: string | null | undefined) {