diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 5c36c3d56..ba079eb25 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -4,14 +4,30 @@ import { type FormEvent, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import type { + GameCreationAppAssetManifestEntry, + GameIterationVersion, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { ThemedModal } from '../../components/modal/ThemedModal'; +import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput'; +import type { + ChatComposerDraft, + ChatReference, +} from '../project-workspace/resourceReferences'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; import { RESOURCE_CANVAS_ASSET_ASPECT_RATIOS, RESOURCE_CANVAS_ASSET_IMAGE_SIZES, type ResourceCanvasAssetToolAction, } from './resourceCanvasBottomToolbarModel'; +import { + resourceCanvasAssetGenerationAcceptsReferences, + resourceCanvasAssetGenerationReferenceAssets, + resourceCanvasAssetGenerationReferenceError, + resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationUserReferenceLimit, +} from './resourceCanvasAssetGenerationReferenceModel'; import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; export type ResourceCanvasAssetGenerationSubmitInput = { @@ -20,6 +36,14 @@ export type ResourceCanvasAssetGenerationSubmitInput = { assetName: string; aspectRatio: string; imageSize: string; + /** + * 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。 + * + * 宿主从这里取 `resourceId`(**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧; + * 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里 + * 历史账号的远端 ID。 + */ + references: ChatReference[]; }; /** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ @@ -28,6 +52,8 @@ export type ResourceCanvasAssetGenerationPanelDraft = { assetName: string; aspectRatio: string; imageSize: string; + /** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */ + references: ChatReference[]; }; export type ResourceCanvasAssetGenerationPanelViewProps = { @@ -40,6 +66,12 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { draft?: ResourceCanvasAssetGenerationPanelDraft; /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ error?: string | null; + /** 当前项目的 manifest 资产:参考选择的候选集由它收口到本项目的已登记图片。 */ + assets?: readonly GameCreationAppAssetManifestEntry[]; + /** `@` 引用选择器需要项目路径来登记资源预览,与快速编辑走同一条链路。 */ + projectPath?: string; + versions?: GameIterationVersion[]; + activeVersionId?: string | null; /** * 提交回调:**同步返回**,面板不等它的结果。 * @@ -73,10 +105,17 @@ export function ResourceCanvasAssetGenerationPanelView({ action, draft, error: initialError, + assets, + projectPath, + versions, + activeVersionId, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [references, setReferences] = useState( + draft?.references ?? [], + ); const [assetName, setAssetName] = useState( draft?.assetName ?? action.assetName, ); @@ -90,13 +129,50 @@ export function ResourceCanvasAssetGenerationPanelView({ // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; + /** + * 参考选择只对有真实参考能力的入口呈现。 + * + * 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器, + * 原生侧同样拒绝额外参考(不是静默丢弃)。 + */ + const referenceEnabled = resourceCanvasAssetGenerationAcceptsReferences(action); + const referenceLimit = + resourceCanvasAssetGenerationUserReferenceLimit(action); + const referenceAssets = resourceCanvasAssetGenerationReferenceAssets( + assets ?? [], + ); + const referenceAssetIds = + resourceCanvasAssetGenerationReferenceIds(references); + const referenceError = resourceCanvasAssetGenerationReferenceError({ + action, + referenceCount: referenceAssetIds.length, + }); + const promptTooLong = prompt.trim().length > promptMaxLength; + const promptTooLongError = promptTooLong + ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` + : null; + const canSubmit = + prompt.trim().length > 0 && + assetName.trim().length > 0 && + !referenceError && + !promptTooLong; + const shownError = error ?? promptTooLongError ?? referenceError; + const applyDraft = (next: ChatComposerDraft) => { + setPrompt(next.text); + setReferences(next.references); + }; function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName) { + // 超限与超长在提交入口再挡一次:按钮禁用只是表现,不能当唯一防线。 + if ( + !normalizedPrompt || + !normalizedAssetName || + referenceError || + promptTooLong + ) { return; } setError(null); @@ -108,6 +184,7 @@ export function ResourceCanvasAssetGenerationPanelView({ assetName: normalizedAssetName, aspectRatio, imageSize, + references, }); onClose(); } @@ -143,16 +220,40 @@ export function ResourceCanvasAssetGenerationPanelView({ {action.adjustableDimensions ? (
@@ -197,9 +298,19 @@ export function ResourceCanvasAssetGenerationPanelView({ prompt={prompt} applyPrompt={setPrompt} /> - {error ? ( + {referenceEnabled && referenceLimit > 0 ? ( +

+ {`参考图 ${referenceAssetIds.length}/${referenceLimit}`} +

+ ) : null} + {shownError ? (

- {error} + {shownError}

) : null}
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index 87e724bd6..2b0ac9634 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -152,6 +152,7 @@ export function createResourceCanvasAssetGenerationQueue( aspectRatio: task.aspectRatio, imageSize: task.imageSize, assetName: task.assetName, + referenceAssetIds: task.referenceAssetIds, outputPath: task.outputPath, })) as LocalProjectAssetGenerationTaskRecord; let started: LocalProjectAssetGenerationTaskRecord; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts new file mode 100644 index 000000000..0af767737 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts @@ -0,0 +1,121 @@ +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { ChatReference } from '../project-workspace/resourceReferences'; +import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel'; + +/** + * 参考图上限:一次生成请求里 `referenceImageSrcs` 的总数,**含**规范图。 + * + * 与 Rust `PLATFORM_ART_MAX_REFERENCE_IMAGES` 同口径:面板负责在提交前挡住超限,原生侧再挡一次, + * 两侧都不做截断(截断就是静默丢弃用户的选择)。 + */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES = 5; + +/** 没有规范图前置时的用户参考上限。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES = 5; + +/** 有规范图前置时:权威规范图自己占掉一张,用户参考最多四张。 */ +export const RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC = 4; + +/** + * 不接受用户参考的生成类型。 + * + * 目前只有 `art-spritesheet`(图集)按合同只接受单张规范引用:这类入口不呈现用户参考选择器, + * 原生提交也会显式拒绝额外参考(不是静默丢弃)。 + * + * 其它入口一律支持用户参考,包括 `icon-spec`(图标规范):它虽然产出权威规范图,但生成时同样 + * 可以带参考图,上限与普通生成一致。 + */ +const RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS: readonly string[] = [ + 'art-spritesheet', +]; + +export function resourceCanvasAssetGenerationAcceptsReferences( + action: ResourceCanvasAssetToolAction, +): boolean { + return !RESOURCE_CANVAS_ASSET_GENERATION_REFERENCE_FREE_KINDS.includes( + action.assetKind, + ); +} + +/** + * 该入口允许用户选几张参考。 + * + * `0` 表示不呈现参考选择器;其余值与原生侧的用户参考上限一致。 + */ +export function resourceCanvasAssetGenerationUserReferenceLimit( + action: ResourceCanvasAssetToolAction, +): number { + if (!resourceCanvasAssetGenerationAcceptsReferences(action)) { + return 0; + } + return action.requiresIconSpecReference + ? RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + : RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES; +} + +/** + * 参考选择的候选集:**当前项目**已登记的图片。 + * + * 直接吃当前 manifest 的 `assets`,所以候选天然限定在同一项目内;再按媒体类型收口到图片, + * 文档、音视频、字体、代码与未落盘的占位都不进候选。清单里没有 `localPath` 的记录(例如只存在于 + * 远端画布、本地还没有文件的条目)同样排除——原生侧要读本地正式文件才能按当前账号重新上传绑定。 + */ +export function resourceCanvasAssetGenerationReferenceAssets( + assets: readonly GameCreationAppAssetManifestEntry[], +): GameCreationAppAssetManifestEntry[] { + return assets.filter( + (asset) => + asset.mediaType.startsWith('image/') && + asset.localPath.trim().length > 0 && + !asset.localPath.startsWith('.agent/'), + ); +} + +/** + * 从草稿里的引用列表取出本次生成的参考资源 ID。 + * + * 只认资源引用(运行态区域引用不是素材);顺序即用户选择顺序,重复选择同一张按一次算。 + * 这些 ID 是**当前项目 manifest 的资产 ID**,不是本地路径,也不是历史远端 ID:原生侧据此读本地 + * 正式文件并按当前账号重新建立远端绑定。 + */ +export function resourceCanvasAssetGenerationReferenceIds( + references: readonly ChatReference[], +): string[] { + const ids: string[] = []; + for (const reference of references) { + if (reference.type !== 'resource') { + continue; + } + const resourceId = reference.resourceId.trim(); + if (!resourceId || ids.includes(resourceId)) { + continue; + } + ids.push(resourceId); + } + return ids; +} + +/** + * 提交前对参考数量的判据。 + * + * 超限时给一条能照着做的原因,而不是把多出来的引用悄悄丢掉:面板据此禁用提交, + * 原生侧仍按同一上限再校验一次。 + */ +export function resourceCanvasAssetGenerationReferenceError({ + action, + referenceCount, +}: { + action: ResourceCanvasAssetToolAction; + referenceCount: number; +}): string | null { + if (!resourceCanvasAssetGenerationAcceptsReferences(action)) { + return null; + } + const limit = resourceCanvasAssetGenerationUserReferenceLimit(action); + if (referenceCount <= limit) { + return null; + } + return action.requiresIconSpecReference + ? `已选 ${referenceCount} 张参考图;该入口会带上权威规范图,用户参考最多 ${limit} 张` + : `已选 ${referenceCount} 张参考图;最多 ${limit} 张`; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index eaa6b87e7..1bb3b32c9 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -41,6 +41,14 @@ export type ResourceCanvasAssetGenerationTask = { prompt: string; aspectRatio: string; imageSize: string; + /** + * 本次生成携带的参考图(当前项目 manifest 的资产 ID,按选择顺序去重)。 + * + * 派发时随 `referenceAssetIds` 交给原生侧:它读本地正式文件并按当前账号重新建立远端绑定, + * 前端不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。恢复出来的历史任务(账本里 + * 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。 + */ + referenceAssetIds: string[]; outputPath: string | null; projectId: string; /** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */ @@ -148,6 +156,7 @@ export function createResourceCanvasAssetGenerationTask(input: { assetName: string; aspectRatio: string; imageSize: string; + referenceAssetIds?: readonly string[]; outputPath: string | null; projectId: string; nowMillis: number; @@ -161,6 +170,7 @@ export function createResourceCanvasAssetGenerationTask(input: { prompt: input.prompt, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + referenceAssetIds: [...(input.referenceAssetIds ?? [])], outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -191,6 +201,7 @@ export function restoreResourceCanvasAssetGenerationTask( prompt: '', aspectRatio: '', imageSize: '', + referenceAssetIds: [], outputPath: null, projectId: record.projectId, dispatched: true, diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index e660c2c73..d713a603a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -120,6 +120,7 @@ import { ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -7261,6 +7262,11 @@ export default function ProjectDevelopmentView({ assetName: input.assetName, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + // 参考图只传**当前 manifest 的资产 ID**:原生侧据此读本地正式文件并按当前账号重新 + // 建立远端绑定,前端既不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。 + referenceAssetIds: resourceCanvasAssetGenerationReferenceIds( + input.references, + ), outputPath: resourceCanvasAssetGenerationOutputPath( action, context.hasIconSpecReference, @@ -7276,6 +7282,7 @@ export default function ProjectDevelopmentView({ assetName: input.assetName, aspectRatio: input.aspectRatio, imageSize: input.imageSize, + references: input.references, }, dispatchedImmediately, }; @@ -8849,6 +8856,11 @@ export default function ProjectDevelopmentView({ : 'fresh' }`} action={resourceAssetGenerationAction} + // 参考选择的候选集来自当前项目 manifest,收口到已登记图片;与快速编辑同一份 `@` 链路。 + assets={manifest.assets} + projectPath={projectPath} + versions={projectVersions} + activeVersionId={activeVersionId} draft={ resourceAssetGenerationPanelReopen?.actionId === resourceAssetGenerationAction.id diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 908aa2669..de46eefd5 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -79,6 +79,31 @@ describe('生成任务模型', () => { RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, ); expect(task.restored).toBe(false); + // 没有选择参考图时就是空列表:原生侧按「没有参考」处理,前端不编造引用。 + expect(task.referenceAssetIds).toEqual([]); + }); + + test('参考图资产 ID 随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { + const task = createResourceCanvasAssetGenerationTask({ + taskId: 'task-ref', + action: uiPrototypeAction, + prompt: '主界面', + assetName: 'UI', + aspectRatio: '16:9', + imageSize: '1K', + referenceAssetIds: ['asset-a', 'asset-b', 'asset-a'], + outputPath: null, + projectId: 'project-1', + nowMillis: 10, + }); + expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b', 'asset-a']); + + const restored = applyLocalProjectAssetGenerationRecords( + [], + [record('task-ref', 'completed')], + ); + expect(restored).toHaveLength(1); + expect(restored[0]?.referenceAssetIds).toEqual([]); }); test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => { @@ -348,6 +373,29 @@ describe('本地排队驱动器', () => { ).toBe('asset-task-b'); }); + test('派发把参考图资产 ID 原样交给原生侧', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) { + api.advance('task-ref', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + await queue.submit({ + ...localTask('task-ref', 10), + referenceAssetIds: ['asset-a', 'asset-b'], + }); + + const startCall = harness.invoke.mock.calls.find( + ([command]) => command === 'start_local_project_asset_generation', + ); + expect(startCall?.[1]).toMatchObject({ + taskId: 'task-ref', + // 只传当前 manifest 的资产 ID:不传本地路径,也不传历史远端 ID。 + referenceAssetIds: ['asset-a', 'asset-b'], + }); + }); + test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => { const harness = createHarness((pollCount, api) => { if (pollCount === 1) { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx new file mode 100644 index 000000000..c70900b2c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + ResourceCanvasAssetGenerationPanelView, + type ResourceCanvasAssetGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC, + resourceCanvasAssetGenerationAcceptsReferences, + resourceCanvasAssetGenerationReferenceAssets, + resourceCanvasAssetGenerationReferenceError, + resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationUserReferenceLimit, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { + type ChatReference, + resourceReferenceFromAsset, +} from '../src/features/project-workspace/resourceReferences'; + +afterEach(() => { + cleanup(); +}); + +function action( + overrides: Partial & + Pick, +): ResourceCanvasAssetToolAction { + return { + route: 'asset', + audioKind: null, + assetName: `素材 ${overrides.label}`, + promptPlaceholder: '描述要生成什么', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, + ...overrides, + }; +} + +const imageAction = action({ + id: 'generate-image', + label: '生成图片', + assetKind: 'image', +}); +const iconSpecAction = action({ + id: 'generate-spec-icon', + label: '图标规范', + assetKind: 'icon-spec', + adjustableDimensions: false, + writesIconSpecReference: true, +}); +const uiPrototypeAction = action({ + id: 'generate-ui-prototype', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + requiresIconSpecReference: true, +}); +const spritesheetAction = action({ + id: 'generate-icon-spritesheet', + label: '生成图标素材', + assetKind: 'art-spritesheet', + requiresIconSpecReference: true, +}); + +function asset( + id: string, + mediaType: string, + localPath: string, + kind = 'image', +): GameCreationAppAssetManifestEntry { + return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; +} + +function resourceReference( + resourceId: string, + label: string, + mediaType = 'image/png', +): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'image', + mediaType, + label, + category: 'scene', + tags: [], + source: 'asset-picker', + }; +} + +describe('参考图模型', () => { + test('只有图集不接受用户参考,图标规范与普通生成一致', () => { + expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe( + true, + ); + expect(resourceCanvasAssetGenerationAcceptsReferences(iconSpecAction)).toBe( + true, + ); + expect(resourceCanvasAssetGenerationAcceptsReferences(uiPrototypeAction)).toBe( + true, + ); + expect( + resourceCanvasAssetGenerationAcceptsReferences(spritesheetAction), + ).toBe(false); + }); + + test('用户参考上限:普通生成五张,自带规范前置的入口四张,图集零张', () => { + expect(resourceCanvasAssetGenerationUserReferenceLimit(imageAction)).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + ); + expect(resourceCanvasAssetGenerationUserReferenceLimit(iconSpecAction)).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + ); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(uiPrototypeAction), + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(spritesheetAction), + ).toBe(0); + // 规范前置入口的四张用户参考 + 一张权威规范图,正好是总上限。 + expect( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES); + }); + + test('候选集只留当前项目已登记的图片', () => { + const candidates = resourceCanvasAssetGenerationReferenceAssets([ + asset('asset-image', 'image/png', 'assets/a.png'), + asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), + asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), + asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), + asset('asset-remote-only', 'image/png', ' '), + ]); + expect(candidates.map((item) => item.id)).toEqual(['asset-image']); + }); + + test('参考 ID 只取资源引用、按选择顺序去重', () => { + expect( + resourceCanvasAssetGenerationReferenceIds([ + resourceReference('asset-a', '素材 A'), + { + type: 'runtime-region', + label: '运行区域', + resourceIds: ['asset-x'], + source: 'runtime-picker', + }, + resourceReference('asset-b', '素材 B'), + resourceReference('asset-a', '素材 A 重名'), + resourceReference(' ', '空身份'), + ]), + ).toEqual(['asset-a', 'asset-b']); + }); + + test('超限给出可执行原因而不是静默截断', () => { + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + }), + ).toBeNull(); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES + 1, + }), + ).toContain('最多 5 张'); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: uiPrototypeAction, + referenceCount: + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + }), + ).toContain('最多 4 张'); + // 图集没有参考选择器:不存在「超限」这条用户可见反馈。 + expect( + resourceCanvasAssetGenerationReferenceError({ + action: spritesheetAction, + referenceCount: 3, + }), + ).toBeNull(); + }); +}); + +describe('生成面板的参考接线', () => { + test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); + const references = [ + resourceReferenceFromAsset(imageAsset, 'asset-picker'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + expect(panel.textContent).toContain('参考图 1/5'); + // 引用输入区与聊天 / 快速编辑同一份组件:可访问名就是「生成提示词」。 + expect(screen.getByLabelText('生成提示词')).not.toBeNull(); + + await user.click( + within(panel).getByRole('button', { name: '生成图片' }), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'image', + prompt: '画一只猫 @素材-a', + references: [expect.objectContaining({ resourceId: 'asset-a' })], + }); + }); + + test('图集入口不呈现参考选择,只提交纯提示词', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(panel.textContent).not.toContain('参考图'); + const prompt = screen.getByLabelText('生成提示词'); + expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA'); + + await user.click( + within(panel).getByRole('button', { name: '生成图标素材' }), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'art-spritesheet', + references: [], + }); + }); + + test('超限时提交被挡住并给出原因', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + + resourceReference(`asset-${id}`, `素材 ${id}`), + ), + }} + onSubmit={onSubmit} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect(panel.textContent).toContain('参考图 5/4'); + const submit = within(panel).getByRole('button', { + name: '生成 UI 设计图', + }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + await user.click(submit); + expect(onSubmit).not.toHaveBeenCalled(); + }); +});