收紧图标图集提示词输入契约

新增独立的 spritesheet prompt 单项字符、聚合字符和 UTF-8 字节上限。

以已验证类型约束 prompt builder,并覆盖 inline、队列与 worker 执行边界。

同步画布校验、Editor Agent 工具合同、External v1 OpenAPI、文档和边界测试。
This commit is contained in:
2026-08-06 16:44:33 +08:00
parent 0b3475ef98
commit 55ff47c6c4
15 changed files with 424 additions and 32 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_GPT_IMAGE_2,
@@ -716,6 +717,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the complete prompt is too long', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
status: 'idle',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
});
});
it('builds icon spritesheet plans with the complete prompt and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -8,6 +8,7 @@ import type {
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
@@ -675,6 +676,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
errorMessage: '请填写素材描述',
};
}
if (Array.from(normalizedPrompt).length > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
return {
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
GenerateDialogState,
UploadTarget,
@@ -206,6 +207,26 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
});
it('limits description input by Unicode characters', () => {
const updateIconDescriptionText = vi.fn();
render(
<IconComposerHarness
initialDialog={createIconDialog({ prompt: '' })}
onUpdateIconDescriptionText={updateIconDescriptionText}
/>,
);
fireEvent.change(screen.getByLabelText('素材描述'), {
target: {
value: '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
},
});
expect(updateIconDescriptionText).toHaveBeenCalledWith(
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
);
});
it('keeps image option changes inside the icon dialog', () => {
const rememberImageModel = vi.fn();
render(
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
@@ -23,6 +24,10 @@ import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationMode
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
function limitIconDescriptionText(value: string) {
return Array.from(value).slice(0, EDITOR_ICON_DESCRIPTION_MAX_CHARS).join('');
}
type ImageCanvasIconSpritesheetComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
@@ -216,7 +221,11 @@ export function ImageCanvasIconSpritesheetComposerView({
disabled={dialog.status === 'generating'}
placeholder="你需要哪些图标?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
onChange={(event) =>
onUpdateIconDescriptionText(
limitIconDescriptionText(event.target.value),
)
}
/>
</label>
{dialog.status === 'failed' ? (
@@ -9,6 +9,9 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
@@ -1150,6 +1153,42 @@ describe('editorProjectClient', () => {
);
});
it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => {
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
}),
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: [
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
...Array.from({ length: 10 }, () =>
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
],
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: Array.from({ length: 8 }, () =>
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('passes image model options to icon spritesheet generation', async () => {
requestJsonMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
@@ -14,6 +14,10 @@ const EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API =
const EDITOR_ICON_SPEC_REFINE_ART_STYLE_API =
'/api/editor/llm/icon-specs/refine-art-style';
export const EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH = 200;
export const EDITOR_ICON_DESCRIPTION_LIMIT = 100;
export const EDITOR_ICON_DESCRIPTION_MAX_CHARS = 200;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS = 2_000;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES = 6 * 1024;
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
@@ -87,6 +91,40 @@ function assertEditorReferenceLimit(
}
}
function normalizeEditorIconDescriptions(values: readonly string[]) {
const normalized = values.map((value) => value.trim()).filter(Boolean);
if (
normalized.length < 1 ||
normalized.length > EDITOR_ICON_DESCRIPTION_LIMIT
) {
throw new Error(
`图标素材描述数量必须在 1 到 ${EDITOR_ICON_DESCRIPTION_LIMIT} 个之间`,
);
}
normalized.forEach((description, index) => {
const actualLength = Array.from(description).length;
if (actualLength > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
throw new Error(
`第 ${index + 1} 条图标素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
);
}
});
const prompt = normalized.join('\n');
const totalLength = Array.from(prompt).length;
if (totalLength > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
}
const totalUtf8Bytes = new TextEncoder().encode(prompt).byteLength;
if (totalUtf8Bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
}
return normalized;
}
function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) {
const references = [
...(input.referenceImageSrcs ?? []),
@@ -1134,6 +1172,9 @@ export async function generateEditorIconSpec(
export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
const iconDescriptions = normalizeEditorIconDescriptions(
input.iconDescriptions,
);
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
@@ -1151,7 +1192,7 @@ export async function generateEditorIconSpritesheet(
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
iconDescriptions: input.iconDescriptions,
iconDescriptions,
model,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),