画布图片生成支持真实参考图引用

新增参考图模型:候选为当前项目已登记图片,用户参考上限普通生成 5 张、带规范前置 4 张、图集为 0
生成浮层复用资源引用输入组件,提示词里的 @ 引用直接产出当前 manifest 资产 ID 作为参考
生成任务与队列新增 referenceAssetIds,派发时以 referenceAssetIds 交给原生侧解析账号绑定
图集入口不呈现参考选择器,超限在提交前给出原因并挡住提交,不做静默截断
补充参考模型、面板接线与队列派发的定向测试
This commit is contained in:
2026-09-17 16:57:21 +08:00
parent ef982fdb78
commit a7b2b0e23b
7 changed files with 617 additions and 14 deletions
@@ -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<ChatReference[]>(
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<HTMLFormElement>) {
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({
</label>
<label>
<span></span>
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
{referenceEnabled ? (
/*
与聊天输入区、资源快速编辑同一个 `@` 引用输入区:候选、`@显示名` 文本与引用模型都
复用那一份,所以参考图带的是**稳定资源 ID**(进而出站到当前账号绑定下的远端资源),
不是只有名字的纯提示词。候选集只放当前项目的已登记图片。
*/
<div className="resource-canvas-asset-generation-prompt-input">
<ResourceReferenceInput
ariaLabel="生成提示词"
value={prompt}
references={references}
onChange={applyDraft}
assets={referenceAssets}
projectPath={projectPath ?? ''}
versions={versions}
activeVersionId={activeVersionId}
multiline
rows={6}
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
showPolishAction={false}
/>
</div>
) : (
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
rows={6}
autoFocus
maxLength={promptMaxLength}
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
)}
</label>
{action.adjustableDimensions ? (
<div className="resource-canvas-asset-generation-dimensions">
@@ -197,9 +298,19 @@ export function ResourceCanvasAssetGenerationPanelView({
prompt={prompt}
applyPrompt={setPrompt}
/>
{error ? (
{referenceEnabled && referenceLimit > 0 ? (
<p
className="resource-canvas-asset-generation-reference-hint"
data-resource-canvas-generation-reference-count={
referenceAssetIds.length
}
>
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
</p>
) : null}
{shownError ? (
<p className="game-resource-generation-error" role="alert">
{error}
{shownError}
</p>
) : null}
<div className="game-resource-generation-actions">
@@ -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;
@@ -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}`;
}
@@ -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,
@@ -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
@@ -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) {
@@ -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<ResourceCanvasAssetToolAction> &
Pick<ResourceCanvasAssetToolAction, 'id' | 'label' | 'assetKind'>,
): 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(
<ResourceCanvasAssetGenerationPanelView
action={imageAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
// 重开草稿的提示词与引用是同一次编辑的产物:`@显示名` 已经在正文里。
prompt: '画一只猫 @素材-a',
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={spritesheetAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: '一套敌人头像',
assetName: '图标素材',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={onSubmit}
onClose={() => 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(
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: '主界面',
assetName: 'UI',
aspectRatio: '16:9',
imageSize: '1K',
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
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();
});
});