node}
+ buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
+ onUpdateSpecFormValue={vi.fn()}
+ onRequestUpload={onRequestUpload}
+ onSubmit={vi.fn()}
+ />
+ );
+}
+
function IconSpecHarness({
playSetting = '回合制占点',
artStyle = '低多边形',
@@ -278,6 +302,71 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
expect(screen.queryByLabelText('界面风格参考')).toBeNull();
});
+ it('removes only the selected character spec reference and keeps its action visible', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getAllByRole('button', { name: /删除参考/ })).toHaveLength(3);
+ fireEvent.click(screen.getByRole('button', { name: '删除参考二' }));
+
+ expect(screen.queryByLabelText('参考二')).toBeNull();
+ expect(screen.getByLabelText('参考一')).toBeTruthy();
+ expect(screen.getByLabelText('参考三')).toBeTruthy();
+ });
+
+ it('shows and removes a legacy non-icon spec reference', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('历史角色参考')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '删除历史角色参考' }));
+ expect(screen.queryByLabelText('历史角色参考')).toBeNull();
+ expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy();
+ });
+
+ it('hides a stale legacy spec reference when canonical references exist', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText('当前角色参考')).toBeTruthy();
+ expect(screen.queryByLabelText('过期角色参考')).toBeNull();
+ });
+
it('renders character spec fields and forwards updates', () => {
const updateSpecFormValue = vi.fn();
renderPanel({
@@ -314,6 +403,17 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
);
});
+ it('keeps the character spec reference source menu open after clicking the add slot', () => {
+ render();
+
+ const addButton = screen.getByRole('button', { name: '参考图' });
+ fireEvent.click(addButton);
+ expect(screen.getByRole('menu', { name: '参考图来源' })).toBeTruthy();
+
+ fireEvent.click(addButton);
+ expect(screen.queryByRole('menu', { name: '参考图来源' })).toBeNull();
+ });
+
it('uses gameplay and art style hints for icon specs', () => {
renderPanel({
dialog: createSpecDialog({
diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx
index 19e960295..877fea08a 100644
--- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx
+++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx
@@ -41,6 +41,10 @@ import type {
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
+import {
+ resolveSpecGenerationReferences,
+ setSpecGenerationReferences,
+} from './ImageCanvasGenerationDialogModel';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorSpecGenerationPrice,
@@ -144,7 +148,13 @@ export function ImageCanvasSpecGenerationPanelView({
: 'spec-reference';
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
- : dialog.specReference;
+ : dialog.specType === 'icon'
+ ? dialog.specReference
+ : null;
+ const extraSpecReferences =
+ !isUiDesignDialog && dialog.specType !== 'icon'
+ ? resolveSpecGenerationReferences(dialog)
+ : [];
const isGenerating = dialog.status === 'generating';
const isIconSpec = dialog.mode === 'spec' && dialog.specType === 'icon';
const [playSettingOptimization, setPlaySettingOptimization] =
@@ -278,7 +288,7 @@ export function ImageCanvasSpecGenerationPanelView({
onPointerDown={(event) => event.stopPropagation()}
>
- {isUiDesignDialog || dialog.specType ? (
+ {isUiDesignDialog || dialog.specType === 'icon' ? (
) : null}
+ {extraSpecReferences.map((item, index) => (
+ }
+ imageSrc={item.src}
+ objectKey={item.objectKey}
+ label={`参考图${index + 1}`}
+ ariaLabel={item.label || `参考图${index + 1}`}
+ title={item.label}
+ disabled={isGenerating}
+ onRemove={
+ setGenerateDialog
+ ? () =>
+ setGenerateDialog((currentDialog) => {
+ if (currentDialog?.mode !== 'spec') {
+ return currentDialog;
+ }
+ return setSpecGenerationReferences(
+ {
+ ...currentDialog,
+ status:
+ currentDialog.status === 'failed'
+ ? 'idle'
+ : currentDialog.status,
+ errorMessage:
+ currentDialog.status === 'failed'
+ ? undefined
+ : currentDialog.errorMessage,
+ },
+ resolveSpecGenerationReferences(currentDialog).filter(
+ (referenceItem) => referenceItem.id !== item.id,
+ ),
+ );
+ })
+ : undefined
+ }
+ removeLabel={`删除${item.label || `参考图${index + 1}`}`}
+ alwaysShowRemove
+ />
+ ))}
+ {!isUiDesignDialog && dialog.specType !== 'icon' ? (
+ }
+ label="参考图"
+ ariaLabel={referenceLabel}
+ disabled={isGenerating}
+ isAdd
+ onClick={openReferenceMenu}
+ />
+ ) : null}
{isUiDesignDialog
? (dialog.generationReferences ?? []).map((item, index) => (
{
imageSrc: 'data:image/png;base64,ref',
}),
).toEqual({
- id: 'upload-character-reference-1781697600000-2',
+ id: 'upload-character-reference-1781697600000-2-1',
label: '参考图3',
src: 'data:image/png;base64,ref',
mediaType: 'image',
@@ -361,6 +361,26 @@ describe('ImageCanvasUploadModel', () => {
});
});
+ it('keeps uploaded reference identities unique across same-time batches', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
+
+ const first = createUploadedGenerationReference({
+ idPrefix: 'upload-spec-reference',
+ index: 0,
+ fallbackLabel: '参考图1',
+ imageSrc: 'data:image/png;base64,first',
+ });
+ const second = createUploadedGenerationReference({
+ idPrefix: 'upload-spec-reference',
+ index: 0,
+ fallbackLabel: '参考图1',
+ imageSrc: 'data:image/png;base64,second',
+ });
+
+ expect(first.id).not.toBe(second.id);
+ });
+
it('resets failed generation dialogs when reference uploads are applied', () => {
expect(
setFailedGenerationIdle(
@@ -381,7 +401,7 @@ describe('ImageCanvasUploadModel', () => {
expect(
applyGenerationReferenceUpload({
- dialog: createDialog({ mode: 'spec' }),
+ dialog: createDialog({ mode: 'spec', specType: 'icon' }),
target: 'spec-reference',
references: [reference],
}),
@@ -415,6 +435,71 @@ describe('ImageCanvasUploadModel', () => {
});
});
+ it('migrates a legacy non-icon spec reference before appending uploads', () => {
+ const legacyReference = {
+ id: 'ref-legacy',
+ label: '历史参考图',
+ src: 'data:image/png;base64,legacy',
+ };
+ const uploadedReferences = Array.from({ length: 5 }, (_, index) => ({
+ id: `ref-new-${index + 1}`,
+ label: `新参考图 ${index + 1}`,
+ src: `data:image/png;base64,new-${index + 1}`,
+ }));
+
+ expect(
+ applyGenerationReferenceUpload({
+ dialog: createDialog({
+ mode: 'spec',
+ specType: 'character',
+ specReference: legacyReference,
+ generationReferences: [],
+ }),
+ target: 'spec-reference',
+ references: uploadedReferences,
+ }),
+ ).toMatchObject({
+ specReference: null,
+ generationReferences: [
+ legacyReference,
+ ...uploadedReferences.slice(0, 4),
+ ],
+ });
+ });
+
+ it('prefers canonical spec references over a stale legacy reference on upload', () => {
+ const canonicalReference = {
+ id: 'ref-current',
+ label: '当前参考图',
+ src: 'data:image/png;base64,current',
+ };
+ const nextReference = {
+ id: 'ref-next',
+ label: '新增参考图',
+ src: 'data:image/png;base64,next',
+ };
+
+ expect(
+ applyGenerationReferenceUpload({
+ dialog: createDialog({
+ mode: 'spec',
+ specType: 'custom',
+ specReference: {
+ id: 'ref-stale',
+ label: '过期参考图',
+ src: 'data:image/png;base64,stale',
+ },
+ generationReferences: [canonicalReference],
+ }),
+ target: 'generation-reference',
+ references: [nextReference],
+ }),
+ ).toMatchObject({
+ specReference: null,
+ generationReferences: [canonicalReference, nextReference],
+ });
+ });
+
it('appends character reference uploads without changing unmatched dialogs', () => {
const previousReference = {
id: 'ref-old',
diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts
index 02077fd62..0528a0193 100644
--- a/src/components/image-editor/ImageCanvasUploadModel.ts
+++ b/src/components/image-editor/ImageCanvasUploadModel.ts
@@ -11,6 +11,10 @@ import type {
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
+import {
+ resolveSpecGenerationReferences,
+ setSpecGenerationReferences,
+} from './ImageCanvasGenerationDialogModel';
import {
appendLimitedImageReferences,
QUICK_EDIT_REFERENCE_LIMIT,
@@ -47,6 +51,8 @@ type GenerationReferenceUploadTarget =
| 'ui-design-icon-spec'
| 'publication-reference';
+let uploadedGenerationReferenceSequence = 0;
+
const VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
@@ -128,11 +134,12 @@ export function createUploadedGenerationReference({
resourceId?: string | null;
sourceAssetId?: string | null;
}): CharacterReferenceImage {
+ uploadedGenerationReferenceSequence += 1;
return {
id:
typeof index === 'number'
- ? `${idPrefix}-${Date.now()}-${index}`
- : `${idPrefix}-${Date.now()}`,
+ ? `${idPrefix}-${Date.now()}-${index}-${uploadedGenerationReferenceSequence}`
+ : `${idPrefix}-${Date.now()}-${uploadedGenerationReferenceSequence}`,
label: fileName || fallbackLabel,
src: imageSrc,
mediaType,
@@ -168,12 +175,19 @@ export function applyGenerationReferenceUpload({
return dialog;
}
if (target === 'spec-reference') {
- return dialog?.mode === 'spec'
- ? {
- ...setFailedGenerationIdle(dialog),
- specReference: firstReference,
- }
- : dialog;
+ if (dialog?.mode !== 'spec') {
+ return dialog;
+ }
+ return setSpecGenerationReferences(
+ setFailedGenerationIdle(dialog),
+ dialog.specType === 'icon'
+ ? [firstReference]
+ : appendLimitedImageReferences(
+ resolveSpecGenerationReferences(dialog),
+ references,
+ resolveDialogExtraImageReferenceLimit(dialog),
+ ),
+ );
}
if (target === 'character-spec') {
return dialog?.mode === 'character'
@@ -220,10 +234,21 @@ export function applyGenerationReferenceUpload({
: dialog;
}
if (target === 'generation-reference') {
+ if (dialog?.mode === 'spec') {
+ return setSpecGenerationReferences(
+ setFailedGenerationIdle(dialog),
+ dialog.specType === 'icon'
+ ? [firstReference]
+ : appendLimitedImageReferences(
+ resolveSpecGenerationReferences(dialog),
+ references,
+ resolveDialogExtraImageReferenceLimit(dialog),
+ ),
+ );
+ }
return dialog?.mode === 'generate' ||
dialog?.mode === 'scene' ||
dialog?.mode === 'video' ||
- dialog?.mode === 'spec' ||
dialog?.mode === 'icon' ||
dialog?.mode === 'ui-design'
? {
diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
index a16367761..b481fdb2c 100644
--- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
+++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
@@ -3960,6 +3960,69 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
+ it('normalizes non-icon spec sources with canonical reference metadata', async () => {
+ generateEditorImageMock.mockResolvedValueOnce(
+ createGenerated({
+ imageSrc: 'data:image/png;base64,character-spec',
+ width: 2048,
+ height: 1152,
+ }),
+ );
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
+ fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
+
+ await waitFor(() => {
+ expect(generateEditorImageMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: 'spec',
+ referenceImageSrcs: ['generated/current-object.png'],
+ generationInputs: expect.objectContaining({
+ references: [expect.objectContaining({ label: '当前参考' })],
+ }),
+ }),
+ );
+ });
+ expect(
+ generateEditorImageMock.mock.calls[0]?.[0]?.referenceImageSrcs,
+ ).not.toContain('generated/legacy-object.png');
+ });
+
it('validates icon submission before calling the API', async () => {
render(
({
+ file,
+ index,
+ imageSrc: await readImageFileAsDataUrl(file),
+ })),
+ );
if (!validateImageReferenceUploadReservation(reservation)) {
return;
}
const references = await persistReferenceUploads(
- [
- {
- idPrefix: 'upload-spec-reference',
- file: imageFile,
- fallbackLabel: '参考图',
- imageSrc,
- },
- ],
+ fileReferences.map(({ file, imageSrc, index }) => ({
+ idPrefix: 'upload-spec-reference',
+ index,
+ file,
+ fallbackLabel: `参考图${index + 1}`,
+ imageSrc,
+ })),
reservation,
);
if (
diff --git a/src/index.css b/src/index.css
index 560074278..87cbc52de 100644
--- a/src/index.css
+++ b/src/index.css
@@ -7024,6 +7024,12 @@ button.image-canvas-editor__reference-chip:disabled {
transform 120ms ease;
}
+.image-canvas-editor__reference-chip-remove--always-visible {
+ opacity: 1;
+ pointer-events: auto;
+ transform: scale(1);
+}
+
.image-canvas-editor__reference-slot:hover
.image-canvas-editor__reference-chip-remove,
.image-canvas-editor__reference-slot:focus-within
@@ -7033,6 +7039,14 @@ button.image-canvas-editor__reference-chip:disabled {
transform: scale(1);
}
+@media (hover: none) {
+ .image-canvas-editor__reference-chip-remove {
+ opacity: 1;
+ pointer-events: auto;
+ transform: scale(1);
+ }
+}
+
.image-canvas-editor__reference-chip-remove:disabled {
cursor: not-allowed;
opacity: 0;