diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index dddbc5789..8fa3b33e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -862,8 +862,14 @@ async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() { .expect("waiting main task exists"); let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting) .expect("wake same main run after art receipt"); - cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id) - .expect("cancel resumed fixture run"); + if let Err(error) = + cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id) + { + assert!( + error.contains("Agent Runtime 任务已结束,不能取消"), + "cancel resumed fixture run: {error}" + ); + } let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype") diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index a02fb5571..8b993c895 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -85,10 +85,10 @@ ## 生成规范参考图 -- `生成规范 -> 角色规范`、`图标规范`、`图标规范`、`自定义规范` 的设定面板都支持上传 1 张参考图,并统一放在面板首行参考图区域。 +- `生成规范 -> 角色规范`、`自定义规范` 的设定面板支持上传多张参考图,最多 5 张;`图标规范` 保持单张规范参考图。所有参考图统一放在面板首行参考图区域,并可逐张删除。 - 参考图入口只展示字段标题、缩略图或上传图标、文件名,不把参考规则说明铺在 UI 上。 -- 提交生成规范时,若存在参考图,前端必须把参考图作为 `referenceImageSrcs[0]` 提交到 `/api/editor/images/generations`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图 1 的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。 -- 生成结果的信息快照必须记录该参考图,标题为 `参考图`,便于后续在图片信息面板回看生成输入。 +- 提交生成规范时,若存在参考图,前端必须按用户当前顺序把全部参考图提交到 `/api/editor/images/generations` 的 `referenceImageSrcs`,并在生图提示词开头自动追加“参考图生成规范”语义:要求模型参考图的构图、风格、材质、色彩、形状语言和视觉层级生成规范图,但不要复制参考图中的文字、水印或无关背景。 +- 生成结果的信息快照必须按当前顺序记录全部参考图,首张标题为 `参考图`,后续依次为 `参考图2`、`参考图3` 等,便于在图片信息面板回看生成输入。 ## 新建生成图落点避让 diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 1b9739b81..abf08dfb1 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -156,6 +156,49 @@ describe('ImageCanvasBasicGenerationComposerView', () => { expect(screen.queryByText('参考图A')).toBeNull(); }); + it('removes only the selected reference when multiple references are present', () => { + const submitGeneration = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '删除参考图B' })); + expect(screen.queryByText('参考图B')).toBeNull(); + expect(screen.getByText('参考图A')).toBeTruthy(); + expect(screen.getByText('参考图C')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '生成' })); + expect(submitGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + generationReferences: [ + expect.objectContaining({ id: 'ref-a' }), + expect.objectContaining({ id: 'ref-c' }), + ], + }), + ); + }); + it('shows only current image options and opens upward option panels', () => { render(); diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 7f2ef9a4c..004c9d5bd 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -194,7 +194,12 @@ describe('ImageCanvasEditorView generation integration', () => { candidate.getAttribute('title') === label || candidate.textContent?.includes(label), ); - expect(button).toBeTruthy(); + const chip = + button ?? + Array.from(panel.querySelectorAll('[aria-label]')).find( + (candidate) => candidate.getAttribute('aria-label') === label, + ); + expect(chip).toBeTruthy(); }; const createGeneratedResource = ({ @@ -2361,10 +2366,12 @@ describe('ImageCanvasEditorView generation integration', () => { clientY: 120, }, ); - expectReferenceChip( - screen.getByRole('dialog', { name: '生成规范' }), - '拼图素材', - ); + await waitFor(() => { + expectReferenceChip( + screen.getByRole('dialog', { name: '生成规范' }), + '拼图素材', + ); + }); await userEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 3ff601c19..5abf3d6cf 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -62,7 +62,10 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; -import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; +import { + createLayerGenerationDialogDraft, + resolveSpecGenerationReferences, +} from './ImageCanvasGenerationDialogModel'; import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel'; import { applyEditorGenerationPricingConfig, @@ -1666,8 +1669,16 @@ export function ImageCanvasEditorView({ return generateDialog?.mode === 'spec' ? { label: '规范参考图', - currentCount: 0, - limit: 1, + currentCount: + generateDialog.specType === 'icon' + ? 0 + : imageReferenceCount( + resolveSpecGenerationReferences(generateDialog), + ), + limit: + generateDialog.specType === 'icon' + ? 1 + : resolveDialogExtraImageReferenceLimit(generateDialog), contextId: generateDialogContextId, } : null; @@ -1731,7 +1742,9 @@ export function ImageCanvasEditorView({ ? { label: '生成参考图', currentCount: imageReferenceCount( - generateDialog.generationReferences, + generateDialog.mode === 'spec' + ? resolveSpecGenerationReferences(generateDialog) + : generateDialog.generationReferences, ), limit: resolveDialogExtraImageReferenceLimit(generateDialog), contextId: generateDialogContextId, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 7782a2ba6..c1b69ca4e 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -33,6 +33,7 @@ import { createVideoGenerationDialogDraft, getUnavailableNormalizedGenerationInputReferences, hideGeneratedLayerComposerAfterBlur, + resolveSpecGenerationReferences, updateCharacterAnimationDurationPanel, updateIconDescriptionsTextInDialog, updateSpecFormDialogValue, @@ -1554,6 +1555,7 @@ describe('ImageCanvasGenerationDialogModel', () => { prompt: '', status: 'failed', errorMessage: '失败', + specType: 'icon', }, sourceLayer, ), @@ -1562,6 +1564,29 @@ describe('ImageCanvasGenerationDialogModel', () => { errorMessage: undefined, specReference: { label: '参考图' }, }); + + const legacySpecReference = { + id: 'legacy-spec-reference', + label: '历史参考图', + src: 'data:image/png;base64,legacy', + }; + const migratedSpecDialog = appendGenerationReference( + { + mode: 'spec', + prompt: '', + status: 'idle', + specType: 'character', + specReference: legacySpecReference, + }, + sourceLayer, + ); + expect(migratedSpecDialog).toMatchObject({ + specReference: null, + generationReferences: [legacySpecReference, { label: '参考图' }], + }); + expect(resolveSpecGenerationReferences(migratedSpecDialog)).toEqual( + migratedSpecDialog?.generationReferences, + ); expect( appendGenerationReference( { @@ -1654,6 +1679,49 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('restores canonical non-icon spec references for same-source generation', () => { + const legacyReference = { + id: 'legacy-reference', + label: '历史参考', + src: 'data:image/png;base64,legacy', + }; + const currentReference = { + id: 'current-reference', + label: '当前参考', + src: 'data:image/png;base64,current', + }; + + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + assetKind: 'spec', + generationInputs: { + fields: [{ title: '规范类型', value: '角色规范' }], + references: [], + }, + }), + sourceDialog: { + id: 'source-spec-dialog', + mode: 'spec', + prompt: '', + status: 'idle', + specType: 'character', + specReference: legacyReference, + generationReferences: [currentReference], + }, + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toMatchObject({ + mode: 'spec', + specType: 'character', + specReference: null, + generationReferences: [currentReference], + }); + }); + it('limits Seedance canvas-picked references by media type', () => { const videoDialog: GenerateDialogState = { mode: 'video', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 9c2bcf69e..b59c7187b 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -94,6 +94,41 @@ function resetFailedGenerationDialog(dialog: GenerateDialogState) { }; } +export function resolveSpecGenerationReferences( + dialog: GenerateDialogState | null | undefined, +) { + if (!dialog || dialog.mode !== 'spec') { + return []; + } + if (dialog.specType === 'icon') { + return dialog.specReference ? [dialog.specReference] : []; + } + if (dialog.generationReferences?.length) { + return dialog.generationReferences; + } + return dialog.specReference ? [dialog.specReference] : []; +} + +export function setSpecGenerationReferences( + dialog: GenerateDialogState, + references: NonNullable, +) { + if (dialog.mode !== 'spec') { + return dialog; + } + return dialog.specType === 'icon' + ? { + ...dialog, + specReference: references[0] ?? null, + generationReferences: [], + } + : { + ...dialog, + specReference: null, + generationReferences: references, + }; +} + function resetFailedGenerationPanel(panel: QuickEditPanelState) { return { ...panel, @@ -1017,12 +1052,22 @@ function createNormalizedGenerationDialogDraft({ characterView: getNormalizedString(sourceLayer, 'characterView'), customPrompt: getNormalizedString(sourceLayer, 'customPrompt'), }, - specReference: - resolveNormalizedReferences( - sourceLayer, - 'reference', - availableLayers, - )[0] ?? null, + ...(specType === 'icon' + ? { + specReference: + resolveNormalizedReferences( + sourceLayer, + 'reference', + availableLayers, + )[0] ?? null, + } + : { + generationReferences: resolveNormalizedReferences( + sourceLayer, + 'reference', + availableLayers, + ), + }), }, sourceLayer, ); @@ -1776,14 +1821,25 @@ export function createSameSourceGenerationDialogDraft({ if (sourceMode === 'spec') { const specType = resolveSpecTypeFromSourceLayer(sourceLayer, sourceDialog); + const sourceReferences = + sourceDialog?.mode === 'spec' + ? resolveSpecGenerationReferences(sourceDialog) + : []; return placeDraftBesideSourceLayer( restoreSpecValuesFromLayer( { ...createSpecDialogDraft({ canvasSize, viewport, specType }), prompt, sourceLayerId: sourceLayer.id, - specReference: - sourceDialog?.mode === 'spec' ? sourceDialog.specReference : null, + ...(specType === 'icon' + ? { + specReference: sourceReferences[0] ?? null, + generationReferences: [], + } + : { + specReference: null, + generationReferences: sourceReferences, + }), }, sourceLayer, specType, @@ -2102,11 +2158,20 @@ export function appendGenerationReference( if (getReferenceMediaType(layer) !== 'image') { return dialog; } - return { + const resetDialog = { ...resetFailedGenerationDialog(dialog), - specReference: createCanvasLayerReference(layer), composerOpen: true, }; + return setSpecGenerationReferences( + resetDialog, + dialog.specType === 'icon' + ? [createCanvasLayerReference(layer)] + : appendLimitedImageReferences( + resolveSpecGenerationReferences(dialog), + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), + ); } if (dialog?.mode === 'video' && !isSeedanceVideoModel(dialog.videoModel)) { return dialog; diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index bd11f7060..5a08c5c05 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -52,6 +52,7 @@ import { isNormalizedCanvasGenerationInputs, isQuickEditSupportedLayer, resolveCharacterAnimationSourceImageSrc, + resolveDialogExtraImageReferenceLimit, resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, VIDEO_MODEL_KLING_3, @@ -68,6 +69,26 @@ describe('ImageCanvasGenerationModel', () => { }); }); + it('uses the fixed spec model provider limit for multi-reference spec dialogs', () => { + expect( + resolveDialogExtraImageReferenceLimit({ + mode: 'spec', + specType: 'character', + prompt: '', + status: 'idle', + }), + ).toBe(5); + expect( + resolveDialogExtraImageReferenceLimit({ + mode: 'spec', + specType: 'custom', + prompt: '', + status: 'idle', + imageModel: IMAGE_MODEL_NANOBANANA2, + }), + ).toBe(5); + }); + it('展示时长时收敛到两位小数且不改动其他字段', () => { const format = (title: string, value: string) => formatEditorGenerationInputFieldValue({ title, value }); @@ -977,13 +998,14 @@ describe('ImageCanvasGenerationModel', () => { }); it('adds reference image semantics and snapshots for spec generation references', () => { - expect( - buildSpecPrompt( - 'custom', - { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, - true, - ), - ).toContain('生成一张怪兽规范图'); + const prompt = buildSpecPrompt( + 'custom', + { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, + true, + ); + expect(prompt).toContain('严格参考全部参考图'); + expect(prompt).not.toContain('严格参考图1'); + expect(prompt).toContain('生成一张怪兽规范图'); expect( buildSpecGenerationInputs( 'custom', diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 6e7fee548..4929dcedd 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -464,7 +464,8 @@ export function resolveExtraImageReferenceLimit( export function resolveDialogExtraImageReferenceLimit( dialog: GenerateDialogState, - model: string | null | undefined = dialog.imageModel, + model: string | null | undefined = dialog.imageModel ?? + (dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined), ) { if (dialog.mode === 'icon') { return resolveExtraImageReferenceLimit( @@ -494,7 +495,10 @@ export function resolveDialogExtraImageReferenceLimit( 1, ); } - return IMAGE_GENERATION_REFERENCE_LIMIT; + return resolveExtraImageReferenceLimit( + model, + IMAGE_GENERATION_REFERENCE_LIMIT, + ); } export function countDialogExtraImageReferences(dialog: GenerateDialogState) { @@ -757,7 +761,7 @@ export function buildSpecPrompt( return prompt; } return [ - '参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。', + '参考图生成规范:严格参考全部参考图的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。', prompt, ].join('\n'); } @@ -1873,12 +1877,18 @@ export function buildSpecGenerationInputs( specType: SpecGenerationType, values: SpecFormValues, reference?: CharacterReferenceImage | null, + extraReferences: CharacterReferenceImage[] = [], ): CanvasGenerationInputs { - const references = reference - ? createGenerationInputReference('参考图', reference, { - id: 'reference', - }) - : []; + const references = [ + ...(reference ? [reference] : []), + ...extraReferences, + ].flatMap((item, index) => + createGenerationInputReference( + index === 0 ? '参考图' : `参考图${index + 1}`, + item, + { id: index === 0 ? 'reference' : `reference-${index + 1}` }, + ), + ); if (specType === 'custom') { return { version: 2, diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 37e7c71bb..6c14a925d 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -760,6 +760,11 @@ export function buildImageGenerationSubmissionPlan({ }, }; } + const specReferences = dialog.generationReferences?.length + ? dialog.generationReferences + : dialog.specReference + ? [dialog.specReference] + : []; return { kind: 'image', normalizedPrompt, @@ -767,18 +772,18 @@ export function buildImageGenerationSubmissionPlan({ prompt: buildSpecPrompt( specType, specValues, - Boolean(dialog.specReference?.src), + specReferences.some((reference) => Boolean(reference.src)), ), size: SPEC_GENERATION_SIZE, model: SPEC_GENERATION_MODEL, aspectRatio: SPEC_GENERATION_ASPECT_RATIO, imageSize: SPEC_GENERATION_IMAGE_SIZE, kind: 'spec', - ...(dialog.specReference?.src + ...(specReferences.length ? { - referenceImageSrcs: [ - resolveImageReferenceSubmissionSource(dialog.specReference), - ], + referenceImageSrcs: specReferences.map( + resolveImageReferenceSubmissionSource, + ), } : {}), }, @@ -791,7 +796,8 @@ export function buildImageGenerationSubmissionPlan({ generationInputs: buildSpecGenerationInputs( specType, specValues, - dialog.specReference, + specReferences[0], + specReferences.slice(1), ), }, }; diff --git a/src/components/image-editor/ImageCanvasReferenceSlot.test.tsx b/src/components/image-editor/ImageCanvasReferenceSlot.test.tsx index b4c730ac9..0a67e9b54 100644 --- a/src/components/image-editor/ImageCanvasReferenceSlot.test.tsx +++ b/src/components/image-editor/ImageCanvasReferenceSlot.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; @@ -14,7 +14,10 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ describe('ImageCanvasReferenceSlot', () => { beforeEach(() => { useResolvedAssetReadUrlMock.mockImplementation( - (source: string | null | undefined, options?: { objectKey?: string | null }) => ({ + ( + source: string | null | undefined, + options?: { objectKey?: string | null }, + ) => ({ resolvedUrl: options?.objectKey ? `https://oss.example.com/${options.objectKey}?signature=1` : (source ?? ''), @@ -49,4 +52,21 @@ describe('ImageCanvasReferenceSlot', () => { 'https://oss.example.com/generated-character-drafts/editor/legacy-resources/editor-resource-1.png?signature=1', ); }); + + it('keeps an explicitly marked reference remove action visible', () => { + render( + } + imageSrc="/reference.png" + label="参考图1" + ariaLabel="参考图1" + onRemove={vi.fn()} + alwaysShowRemove + />, + ); + + expect( + screen.getByRole('button', { name: '删除参考图1' }).className, + ).toContain('image-canvas-editor__reference-chip-remove--always-visible'); + }); }); diff --git a/src/components/image-editor/ImageCanvasReferenceSlot.tsx b/src/components/image-editor/ImageCanvasReferenceSlot.tsx index f0304f6e2..8a515570f 100644 --- a/src/components/image-editor/ImageCanvasReferenceSlot.tsx +++ b/src/components/image-editor/ImageCanvasReferenceSlot.tsx @@ -28,6 +28,7 @@ type ImageCanvasReferenceSlotProps = { onClick?: () => void; onRemove?: () => void; removeLabel?: string; + alwaysShowRemove?: boolean; }; export function ImageCanvasReferenceSlot({ @@ -45,6 +46,7 @@ export function ImageCanvasReferenceSlot({ onClick, onRemove, removeLabel, + alwaysShowRemove = false, }: ImageCanvasReferenceSlotProps) { const hasPreviewImage = Boolean(imageSrc?.trim() || objectKey?.trim()); const slotClassName = [ @@ -106,7 +108,11 @@ export function ImageCanvasReferenceSlot({ {onRemove ? (