收紧图标图集提示词输入契约
新增独立的 spritesheet prompt 单项字符、聚合字符和 UTF-8 字节上限。 以已验证类型约束 prompt builder,并覆盖 inline、队列与 worker 执行边界。 同步画布校验、Editor Agent 工具合同、External v1 OpenAPI、文档和边界测试。
This commit is contained in:
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user