画布生成面板重开草稿与重试身份收敛到 canonical content

生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[]
重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch
directCodexContentToLegacyContentDto 只在任务落账的出站边界派生
账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现
token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用
无法解析的资源引用合成 unknown 占位而不再静默丢弃
出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用
directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定
同步决策记录与 canonical content 里程碑落地结果
This commit is contained in:
2026-09-21 22:56:32 +08:00
parent 8968762274
commit a11cdf13d7
14 changed files with 505 additions and 297 deletions
@@ -81,9 +81,11 @@ import {
ResourceReferenceNode,
} from './ResourceReferenceNode';
import {
buildContentFromTextTokens,
type ChatComposerDraft,
type ChatReference,
chatReferenceToContentPart,
contentPartMentionToken,
currentIterationVersionAssets,
dedupeChatReferences,
directCodexContentToPromptText,
@@ -267,115 +269,9 @@ export function readResourceReferenceDraft(
return editorState.read(readDraftFromNodes);
}
type DraftBuildSegment =
| { kind: 'text'; text: string }
| { kind: 'part'; part: DirectCodexUserContentPart };
type DraftBuildCandidate = {
token: string;
part: DirectCodexUserContentPart;
};
/**
* part 在润色文本里的可扫描 token:与 `directCodexContentToPromptText` 出站给润色服务的
* 口径逐字一致——附件是 `@附件名`,资源与 runtime 引用是 `@显示名`Skill 是 `$名称`。
* `input_text` 没有 token,返回 `null`。
*/
function draftScanToken(
part: DirectCodexUserContentPart,
resourceLabels: ReadonlyMap<string, string>,
): string | null {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resourceLabels.get(part.resourceId) ?? part.resourceId}`;
}
function isDraftMentionBoundary(character: string | undefined) {
return character === undefined || character === '' || /\s/u.test(character);
}
/**
* 在 `from` 之后找 `token` 的整标记位置:前后必须是行首 / 行尾或空白,
* 避免 `@hero` 命中 `@hero2` 的前缀。
*/
function findDraftMentionToken(line: string, token: string, from: number) {
let index = line.indexOf(token, from);
while (index >= 0) {
if (
isDraftMentionBoundary(index === 0 ? undefined : line[index - 1]) &&
isDraftMentionBoundary(line[index + token.length])
) {
return index;
}
index = line.indexOf(token, index + 1);
}
return -1;
}
/**
* 把「润色回包文本 + 待恢复 part」切成编辑区要写的内容。
*
* 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts`
* 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上,
* 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。
*
* 文本里已经没有对应 token 的 part(例如 AI 润色整体改写了文本)内联补到最后一段末尾,
* 引用与附件不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。
*/
function buildDraftSegments(
value: string,
candidates: readonly DraftBuildCandidate[],
): DraftBuildSegment[][] {
const pending = candidates.map((candidate) => ({
...candidate,
used: false,
}));
const lines: DraftBuildSegment[][] = [];
for (const line of value.split(/\r?\n/u)) {
const segments: DraftBuildSegment[] = [];
let cursor = 0;
for (;;) {
const match = pending
.filter((item) => !item.used)
.map((item) => ({
item,
index: findDraftMentionToken(line, item.token, cursor),
}))
.filter((candidate) => candidate.index >= 0)
.sort((left, right) => left.index - right.index)
.at(0);
if (!match) break;
if (match.index > cursor) {
segments.push({ kind: 'text', text: line.slice(cursor, match.index) });
}
match.item.used = true;
segments.push({ kind: 'part', part: match.item.part });
cursor = match.index + match.item.token.length;
}
if (cursor < line.length) {
segments.push({ kind: 'text', text: line.slice(cursor) });
}
lines.push(segments);
}
const orphans = pending
.filter((item) => !item.used)
.map((item): DraftBuildSegment => ({ kind: 'part', part: item.part }));
if (orphans.length > 0) {
const lastLine = lines.at(-1);
if (!lastLine) return [orphans];
if (lastLine.length > 0) {
lastLine.push({ kind: 'text', text: ' ' });
}
lastLine.push(...orphans);
}
return lines;
}
/**
* 润色回写:回包文本里还剩哪个 token,就在那个位置换回真的 part——引用与附件同一套扫描
* 口径(`draftScanToken`),所以润色改过的那段文字里如果还留着 `@显示名` / `$名称` /
* 口径(`contentPartMentionToken`),所以润色改过的那段文字里如果还留着 `@显示名` / `$名称` /
* `@附件名`,chip 原位复活;整体被改写的那些 part 补在末尾,不丢。
*
* TODO: 润色服务整体改写文本、把 `@名称` token 也删掉时,前端无法反推原位置,只能补在末尾。
@@ -396,25 +292,10 @@ function applyPolishedTextToRoot(
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
const candidates = current.content.flatMap((part) => {
const token = draftScanToken(part, resourceLabels);
const token = contentPartMentionToken(part, resourceLabels);
return token ? [{ token, part }] : [];
});
const lines = value.split(/\r?\n/u);
const rebuiltContent: DirectCodexUserContentPart[] = [];
buildDraftSegments(value, candidates).forEach((segments, lineIndex) => {
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text)
rebuiltContent.push({ type: 'input_text', text: segment.text });
return;
}
rebuiltContent.push(segment.part);
});
if (lineIndex < lines.length - 1) {
rebuiltContent.push({ type: 'input_text', text: '\n' });
}
});
applyContentToRoot(rebuiltContent, assetsById);
applyContentToRoot(buildContentFromTextTokens(value, candidates), assetsById);
}
function referenceFromContentPart(
@@ -127,7 +127,12 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
);
}
/** canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。 */
/**
* canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。
*
* **逐字投影,不裁剪空白**:前端不替用户改写输入,只有整条 content 的全空白判定
* `hasMeaningfulDirectCodexContent`)算「空」。需要首尾裁剪的调用方在自己的边界做。
*/
export function directCodexContentToPromptText(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
@@ -152,8 +157,7 @@ export function directCodexContentToPromptText(
}
return '';
})
.join('')
.trim();
.join('');
}
/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */
@@ -165,6 +169,33 @@ export function hasMeaningfulDirectCodexContent(
);
}
/**
* canonical content 的结构指纹:合并相邻 `input_text`、丢弃空串,非文本 part 原样按序序列化。
*
* 只用来判断「这份输入有没有被改过」(例如重试面板的原请求身份比对)。编辑器读回的文本节点
* 切分粒度可能与写进去的不同,逐 part 深比较会把「没改」误判成「改过」,所以比较前先做粒度
* 无关化。逐字保留空白,不做裁剪。
*/
export function directCodexContentKey(
content: readonly DirectCodexUserContentPart[],
): string {
const normalized: DirectCodexUserContentPart[] = [];
content.forEach((part) => {
if (part.type !== 'input_text') {
normalized.push(part);
return;
}
if (part.text === '') return;
const last = normalized.at(-1);
if (last?.type === 'input_text') {
last.text += part.text;
return;
}
normalized.push({ type: 'input_text', text: part.text });
});
return JSON.stringify(normalized);
}
export function chatReferenceToContentPart(
reference: ChatReference,
): DirectCodexUserContentPart {
@@ -188,6 +219,166 @@ export function chatReferenceToContentPart(
};
}
/**
* part 在「文本 → content」反解析里的可扫描 token:与 `directCodexContentToPromptText`
* 出站口径逐字一致——附件是 `@附件名`,资源与 runtime 引用是 `@显示名`Skill 是 `$名称`。
* `input_text` 没有 token,返回 `null`。
*/
export function contentPartMentionToken(
part: DirectCodexUserContentPart,
resourceLabels: ReadonlyMap<string, string>,
): string | null {
if (part.type === 'input_text') return null;
if (part.type === 'agc_attachment_reference') return `@${part.name}`;
if (part.type === 'agc_skill_reference') return `$${part.name}`;
if (part.type === 'agc_runtime_region_reference') return `@${part.label}`;
return `@${resourceLabels.get(part.resourceId) ?? part.resourceId}`;
}
/** 单条引用在文本里的可扫描 token(引用自带显示名,不必再查 manifest)。 */
export function chatReferenceMentionToken(reference: ChatReference): string {
return reference.type === 'skill'
? `$${reference.name}`
: `@${reference.label}`;
}
/** 一条待恢复 part 与它在文本里的 token。 */
export type ContentTokenCandidate = {
token: string;
part: DirectCodexUserContentPart;
};
type ContentTokenSegment =
| { kind: 'text'; text: string }
| { kind: 'part'; part: DirectCodexUserContentPart };
function isMentionTokenBoundary(character: string | undefined) {
return character === undefined || character === '' || /\s/u.test(character);
}
/**
* 在 `from` 之后找 `token` 的整标记位置:前后必须是行首 / 行尾或空白,
* 避免 `@hero` 命中 `@hero2` 的前缀。
*/
function findMentionToken(line: string, token: string, from: number) {
let index = line.indexOf(token, from);
while (index >= 0) {
if (
isMentionTokenBoundary(index === 0 ? undefined : line[index - 1]) &&
isMentionTokenBoundary(line[index + token.length])
) {
return index;
}
index = line.indexOf(token, index + 1);
}
return -1;
}
/**
* 文本 + 待恢复 part → canonical content(文本 → content 的唯一反解析)。
*
* 不变量:写回后编辑器读到的草稿就是这次翻译的唯一结果。编辑器把每个 chip 读成一段
* `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上;文本里已经
* 找不到 token 的 part(整体被改写 / legacy 文本里根本没有它)内联补到末尾,引用与附件绝不
* 凭空消失。逐字保留空白,不做裁剪,只在整条 content 上判空。
*
* TODO: 文本被整体改写、连 token 也删掉时,前端无法反推原位置,只能补在末尾。需要「精确恢复
* 引用 / 附件原位置」时由上游协议携带位置信息,不在前端猜字符串。
*/
export function buildContentFromTextTokens(
value: string,
candidates: readonly ContentTokenCandidate[],
): DirectCodexUserContentPart[] {
const pending = candidates.map((candidate) => ({
...candidate,
used: false,
}));
const lineSegments: ContentTokenSegment[][] = [];
for (const line of value.split(/\r?\n/u)) {
const segments: ContentTokenSegment[] = [];
let cursor = 0;
for (;;) {
const match = pending
.filter((item) => !item.used)
.map((item) => ({
item,
index: findMentionToken(line, item.token, cursor),
}))
.filter((candidate) => candidate.index >= 0)
.sort((left, right) => left.index - right.index)
.at(0);
if (!match) break;
if (match.index > cursor) {
segments.push({ kind: 'text', text: line.slice(cursor, match.index) });
}
match.item.used = true;
segments.push({ kind: 'part', part: match.item.part });
cursor = match.index + match.item.token.length;
}
if (cursor < line.length) {
segments.push({ kind: 'text', text: line.slice(cursor) });
}
lineSegments.push(segments);
}
const orphans = pending
.filter((item) => !item.used)
.map((item): ContentTokenSegment => ({ kind: 'part', part: item.part }));
if (orphans.length > 0) {
const lastLine = lineSegments.at(-1);
if (!lastLine) {
lineSegments.push(orphans);
} else {
if (lastLine.length > 0) {
lastLine.push({ kind: 'text', text: ' ' });
}
lastLine.push(...orphans);
}
}
const content: DirectCodexUserContentPart[] = [];
lineSegments.forEach((segments, lineIndex) => {
segments.forEach((segment) => {
if (segment.kind === 'text') {
if (segment.text) {
content.push({ type: 'input_text', text: segment.text });
}
return;
}
content.push(segment.part);
});
if (lineIndex < lineSegments.length - 1) {
content.push({ type: 'input_text', text: '\n' });
}
});
return content;
}
/**
* legacy「text + references + attachments」DTO → canonical content:旧调用方继续吐双轨
* 形状,这里按 token 扫回真 part,不另立第二份事实源。
*
* 面板重开草稿、润色回包等 legacy 输入都走这一条翻译;文本里保留着 `@显示名` / `$名称` /
* `@附件名` 就能原位恢复 chip,整体被改写的 part 补在末尾(见 `buildContentFromTextTokens`)。
*/
export function legacyContentDtoToContent(dto: {
text: string;
references: readonly ChatReference[];
attachments?: readonly DirectCodexUserAttachmentReferencePart[];
}): DirectCodexUserContentPart[] {
return buildContentFromTextTokens(dto.text, [
...dto.references.map((reference) => ({
token: chatReferenceMentionToken(reference),
part: chatReferenceToContentPart(reference),
})),
...(dto.attachments ?? []).map((attachment) => ({
token: `@${attachment.name}`,
part: {
type: 'agc_attachment_reference' as const,
...attachment,
},
})),
]);
}
export function chatComposerDraftToDirectCodexUserItem(
draft: ChatComposerDraft,
id: string,
@@ -207,6 +398,27 @@ export function directCodexUserItemFromContent(
return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id);
}
/**
* 资源引用在 manifest 里找不到对应资产时(已删除 / 已改名)合成的占位引用。
*
* canonical content 里那个 `agc_resource_reference` 还在,翻译就**不能把它丢掉**:丢掉等于把
* 「带参考」静默变成「无参考」的付费生成。这里保留它保留下来的身份(`resourceId` 作显示名、
* kind 收口成 `unknown`),交给调用方按同一份 manifest 显式报「已不在当前项目」,由用户决定
* 移除还是重选,而不是替用户删掉。
*/
function unresolvedResourceReference(resourceId: string): ResourceReference {
return {
type: 'resource',
resourceId,
kind: 'unknown',
mediaType: '',
label: resourceId,
category: 'unclassified',
tags: [],
source: 'asset-picker',
};
}
/** canonical content → legacy text + reference + attachment DTO。只允许单向翻译。 */
export function directCodexContentToLegacyContentDto(
content: readonly DirectCodexUserContentPart[],
@@ -216,8 +428,11 @@ export function directCodexContentToLegacyContentDto(
content.forEach((part) => {
if (part.type === 'agc_resource_reference') {
const asset = assets.find((item) => item.id === part.resourceId);
if (asset)
references.push(resourceReferenceFromAsset(asset, 'asset-picker'));
references.push(
asset
? resourceReferenceFromAsset(asset, 'asset-picker')
: unresolvedResourceReference(part.resourceId),
);
return;
}
if (part.type === 'agc_runtime_region_reference') {
@@ -21,11 +21,13 @@ import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/i
import { ThemedModal } from '../../components/modal/ThemedModal';
import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart';
import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel';
import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput';
import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../project-workspace/ResourceReferenceInput';
import {
type ChatComposerDraft,
type ChatReference,
chatReferenceToContentPart,
directCodexContentKey,
directCodexContentToLegacyContentDto,
} from '../project-workspace/resourceReferences';
import {
@@ -33,11 +35,11 @@ import {
resourceCanvasAssetGenerationReferenceAssets,
resourceCanvasAssetGenerationReferenceError,
resourceCanvasAssetGenerationReferenceIds,
resourceCanvasAssetGenerationReferenceIdsMatch,
resourceCanvasAssetGenerationReferenceIssue,
resourceCanvasAssetGenerationReferenceProblems,
resourceCanvasAssetGenerationUserReferenceLimit,
} from './resourceCanvasAssetGenerationReferenceModel';
import { resourceCanvasAssetGenerationPromptText } from './resourceCanvasAssetGenerationTaskModel';
import {
RESOURCE_CANVAS_ASSET_ASPECT_RATIOS,
RESOURCE_CANVAS_ASSET_IMAGE_SIZES,
@@ -47,28 +49,30 @@ import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot';
export type ResourceCanvasAssetGenerationSubmitInput = {
kind: ResourceCanvasAssetToolAction['assetKind'];
prompt: string;
/**
* 本次生成的 canonical 输入(唯一事实源):提示词与 `@` 引用都在这一份 content 里。
*
* 面板**不**在这里拆出 `prompt` / `references` 双轨——宿主在任务落账那一个出站边界上才用
* `directCodexContentToLegacyContentDto` 翻译出账本要的文本与参考身份。
*/
content: DirectCodexUserContentPart[];
assetName: string;
aspectRatio: string;
imageSize: string;
/**
* 本次生成的参考图引用(面板草稿与重开草稿的同一种形状)。
*
* 宿主从这里取 `resourceId`**当前项目 manifest 的资产 ID**,按选择顺序去重)交给原生侧;
* 原生据此读本地正式文件并按当前账号重新建立远端绑定,不接受本地路径,也不复用 manifest 里
* 历史账号的远端 ID。
*/
references: ChatReference[];
};
/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */
/**
* 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。
*
* 与面板自身同形状:**只有一份 canonical `content`**(提示词与 `@` 引用都在里面)+ 三个非文本
* 参数。面板的输入状态、重开草稿、重试身份判据与提交载荷全部是这一种形状,不再各带一份
* `prompt + references` 的 DTO。
*/
export type ResourceCanvasAssetGenerationPanelDraft = {
prompt: string;
content: DirectCodexUserContentPart[];
assetName: string;
aspectRatio: string;
imageSize: string;
/** 提示词里的 `@显示名` 引用节点;参考选择器的候选项与它们同源。 */
references: ChatReference[];
};
export type ResourceCanvasAssetGenerationPanelViewProps = {
@@ -164,10 +168,23 @@ export function ResourceCanvasAssetGenerationPanelView({
onSubmit,
onClose,
}: ResourceCanvasAssetGenerationPanelViewProps) {
const [prompt, setPrompt] = useState(draft?.prompt ?? '');
const [references, setReferences] = useState<ChatReference[]>(
draft?.references ?? [],
/**
* 面板的唯一草稿事实源:`content[]`。
*
* 面板**只**存这一份 canonical 输入:重开草稿、卸载时交回的草稿、重试身份判据与提交载荷
* 都是它;提示词文本与引用列表只是渲染期的单向投影(下面的
* `directCodexContentToLegacyContentDto`),用来做字数、计数与失效参考的判据。
*/
const [content, setContent] = useState<DirectCodexUserContentPart[]>(() =>
draft ? [...draft.content] : [],
);
/**
* 润色要作用在**编辑器里的那份 content** 上,不能只改面板的派生文本。
*
* 挂引用输入区时走它的 `replaceText`:回包文本里的 `@显示名` 原位换回真引用 chip;
* 没有引用输入区的纯文本分支没有编辑器,直接写回一条 `input_text`。
*/
const promptInputRef = useRef<ResourceReferenceInputHandle | null>(null);
const [assetName, setAssetName] = useState(
draft?.assetName ?? action.assetName,
);
@@ -181,25 +198,21 @@ export function ResourceCanvasAssetGenerationPanelView({
/**
* 原请求冻结下来的输入(只有「已提交请求的重试」才有)。
*
* 已提交请求的身份由动作指纹(提示词、素材名、比例、尺寸等)定位,参考集合则进账本请求正文
* 并在恢复时逐项比对:改掉其中任何一,这次提交就不再是原请求的重试——可能被原生拒绝恢复,
* 冻结的就是 canonical `content` 本身(不是它的 text / references 投影):改掉正文、增删
* 引用、或动三个非文本参数中的任何一,这次提交就不再是原请求的重试——可能被原生拒绝恢复,
* 也可能成为一次不同的付费意图,而用户以为自己只是在重试。所以这里冻结一份对照值、提交前
* 逐项比对,不靠文案提醒。
* 按内容指纹逐项比对,不靠文案提醒。
* 取挂载那一帧的 `draft`:宿主重建重试草稿时用的就是账本里原始请求的输入。
*/
const boundRequestRef = useRef<{
referenceIds: string[];
prompt: string;
content: DirectCodexUserContentPart[];
assetName: string;
aspectRatio: string;
imageSize: string;
} | null>(
retryOfRequest
? {
referenceIds: resourceCanvasAssetGenerationReferenceIds(
draft?.references ?? [],
),
prompt: draft?.prompt ?? '',
content: draft ? [...draft.content] : [],
assetName: draft?.assetName ?? action.assetName,
aspectRatio: draft?.aspectRatio ?? action.aspectRatio,
imageSize: draft?.imageSize ?? action.imageSize,
@@ -215,11 +228,10 @@ export function ResourceCanvasAssetGenerationPanelView({
* 是最新草稿。`onDraftPersist` 也走 ref,避免闭包被第一帧冻住。
*/
const latestDraftRef = useRef<ResourceCanvasAssetGenerationPanelDraft>({
prompt: draft?.prompt ?? '',
content: draft ? [...draft.content] : [],
assetName: draft?.assetName ?? action.assetName,
aspectRatio: draft?.aspectRatio ?? action.aspectRatio,
imageSize: draft?.imageSize ?? action.imageSize,
references: draft?.references ?? [],
});
const persistDraftRef = useRef(onDraftPersist);
persistDraftRef.current = onDraftPersist;
@@ -238,12 +250,25 @@ export function ResourceCanvasAssetGenerationPanelView({
action.assetKind === 'icon-spritesheet'
? EDITOR_ICON_DESCRIPTION_MAX_CHARS
: resourceEditPromptMaxLength('image-reference');
// Rust str::trim 使用 Unicode White_SpaceJS trim 对 NEL/BOM 的定义不同。
const normalizePrompt = (value: string) =>
action.assetKind === 'icon-spritesheet'
? value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '')
: value.trim();
const normalizedPrompt = normalizePrompt(prompt);
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
assets ?? [],
);
/*
content 是唯一事实源:提示词与引用在这里单向派生,面板不各存一份镜像。
引用解析吃**完整 manifest**(不是收口到图片的候选集):候选集只决定 `@` 面板能选什么,
已存在的引用必须能解析出 kind / 媒体类型,才轮得到 `resourceCanvasAssetGenerationReferenceProblems`
报「不是图片 / 没有本地文件」,而不是在这里被静默丢掉。
*/
const { text: prompt, references } = directCodexContentToLegacyContentDto(
content,
assets ?? [],
);
// 出站口径的规范化与任务落账共用同一个函数(面板只管校验,宿主在出站边界用同一句话)。
const normalizedPrompt = resourceCanvasAssetGenerationPromptText(
action,
prompt,
);
/**
* 参考选择只对有真实参考能力的入口呈现。
*
@@ -260,9 +285,6 @@ export function ResourceCanvasAssetGenerationPanelView({
resourceCanvasAssetGenerationAcceptsReferences(action);
const referenceLimit =
resourceCanvasAssetGenerationUserReferenceLimit(action);
const referenceAssets = resourceCanvasAssetGenerationReferenceAssets(
assets ?? [],
);
const referenceAssetIds =
resourceCanvasAssetGenerationReferenceIds(references);
const referenceError = resourceCanvasAssetGenerationReferenceError({
@@ -308,11 +330,8 @@ export function ResourceCanvasAssetGenerationPanelView({
*/
const boundRequestChanged =
boundRequestRef.current !== null &&
(!resourceCanvasAssetGenerationReferenceIdsMatch(
boundRequestRef.current.referenceIds,
referenceAssetIds,
) ||
normalizedPrompt !== normalizePrompt(boundRequestRef.current.prompt) ||
(directCodexContentKey(content) !==
directCodexContentKey(boundRequestRef.current.content) ||
assetName.trim() !== boundRequestRef.current.assetName.trim() ||
aspectRatio !== boundRequestRef.current.aspectRatio ||
imageSize !== boundRequestRef.current.imageSize);
@@ -326,41 +345,29 @@ export function ResourceCanvasAssetGenerationPanelView({
!boundRequestChanged;
const shownError =
error ?? referenceIssue ?? promptTooLongError ?? referenceError;
// `@` 引用输入区是 canonical content 的唯一事实源:这里按「提示词 + 已存引用」一次性
// 回填草稿,之后的每次编辑都由 `onChange` 反向投影回本面板自己的草稿状态。
const [initialPromptContent] = useState<DirectCodexUserContentPart[]>(() => [
...(draft?.prompt
? [{ type: 'input_text' as const, text: draft.prompt }]
: []),
...(draft?.references ?? []).map(chatReferenceToContentPart),
]);
const applyDraft = (next: ChatComposerDraft) => {
const legacyDraft = directCodexContentToLegacyContentDto(
next.content,
referenceAssets,
);
setPrompt(legacyDraft.text);
setReferences(legacyDraft.references);
setContent(next.content);
};
const applyPolishedPrompt = (text: string) => {
const composer = promptInputRef.current;
if (composer) {
composer.replaceText(text);
return;
}
setContent([{ type: 'input_text', text }]);
};
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
const closeWithDraft = () => {
// 输入已经明确交回宿主:卸载时不再写回一份内存副本(不然「关闭」会复活已提交的草稿)。
draftReleasedRef.current = true;
onClose({
prompt,
assetName,
aspectRatio,
imageSize,
references,
});
onClose({ content, assetName, aspectRatio, imageSize });
};
// 卸载(切占位 / 换浮层)时交出的就是这一帧的草稿。
latestDraftRef.current = {
prompt,
content,
assetName,
aspectRatio,
imageSize,
references,
};
/**
* 失效参考的处置说明。
@@ -413,11 +420,10 @@ export function ResourceCanvasAssetGenerationPanelView({
// (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。
onSubmit({
kind: action.assetKind,
prompt: normalizedPrompt,
content,
assetName: normalizedAssetName,
aspectRatio,
imageSize,
references,
});
// 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。
onClose();
@@ -463,8 +469,9 @@ export function ResourceCanvasAssetGenerationPanelView({
*/
<div className="resource-canvas-asset-generation-prompt-input">
<ResourceReferenceInput
ref={promptInputRef}
ariaLabel="生成提示词"
initialContent={initialPromptContent}
initialContent={content}
onChange={applyDraft}
assets={referenceAssets}
projectPath={projectPath ?? ''}
@@ -484,7 +491,11 @@ export function ResourceCanvasAssetGenerationPanelView({
autoFocus
placeholder={action.promptPlaceholder}
value={prompt}
onChange={(event) => setPrompt(event.currentTarget.value)}
onChange={(event) =>
setContent([
{ type: 'input_text', text: event.currentTarget.value },
])
}
/>
)}
</div>
@@ -577,7 +588,7 @@ export function ResourceCanvasAssetGenerationPanelView({
subject={`素材生成提示词(${action.label}`}
editKind="image-reference"
prompt={prompt}
applyPrompt={setPrompt}
applyPrompt={applyPolishedPrompt}
/>
{referenceEnabled && referenceLimit > 0 ? (
<p
@@ -251,24 +251,6 @@ export function resourceCanvasAssetGenerationReferenceLabels(
return labels;
}
/**
* 原请求冻结的参考集合与当前草稿是否仍然是同一份(顺序敏感)。
*
* 重试面板用它做「输入有没有被改动」的判据:参考集合进的是账本请求正文(`referenceImageSrcs`),
* 恢复时逐项比对(见 Rust `resolve_platform_art_generation_references_at`)。删掉一张再提交就
* 不再是原请求的重试——可能被原生拒绝恢复、也可能成为一次不同的付费意图,而用户以为自己只是
* 在重试。集合不同就不许提交,由用户显式选择恢复原参考或另起新请求。
*/
export function resourceCanvasAssetGenerationReferenceIdsMatch(
bound: readonly string[],
current: readonly string[],
): boolean {
return (
bound.length === current.length &&
bound.every((resourceId, index) => resourceId === current[index])
);
}
/**
* 用任务上冻结的参考身份重建重试草稿:**失效参考保留原 ID 与原名,不静默丢弃**。
*
@@ -194,6 +194,22 @@ export function resourceCanvasAssetGenerationTaskIsTerminal(
return task.status === 'completed' || task.status === 'failed';
}
/**
* 出站提示词:面板校验与任务落账用**同一条**规范化,两端不许各写一份。
*
* Rust `str::trim` 使用 Unicode White_SpaceJS `String.prototype.trim` 对 NEL / BOM 的定义不同,
* 所以图集(`icon-spritesheet`)按 Unicode 口径收边;其余图片入口沿用 JS 口径。这里只收首尾空白,
* 不做别的改写。
*/
export function resourceCanvasAssetGenerationPromptText(
action: ResourceCanvasAssetToolAction,
prompt: string,
): string {
return action.assetKind === 'icon-spritesheet'
? prompt.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '')
: prompt.trim();
}
/** 新提交的任务:先本地排队,派发之前不进后端账本。 */
export function createResourceCanvasAssetGenerationTask(input: {
taskId: string;
@@ -217,7 +233,8 @@ export function createResourceCanvasAssetGenerationTask(input: {
actionLabel: input.action.label,
assetKind: input.action.assetKind,
assetName: input.assetName,
prompt: input.prompt,
// 账本里的提示词就是出站那份:规范化只在 `resourceCanvasAssetGenerationPromptText` 一处发生。
prompt: resourceCanvasAssetGenerationPromptText(input.action, input.prompt),
idempotencyKey: null,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
@@ -124,6 +124,7 @@ import {
dispatchResourceReferenceInsert,
dispatchResourceReferenceInsertMany,
isResourceReferenceOverlayTarget,
legacyContentDtoToContent,
resolveActiveIterationVersion,
resourceDisplayName,
resourceReferenceCategoryLabel,
@@ -8774,23 +8775,31 @@ export default function ProjectDevelopmentView({
task.dispatched &&
!resourceCanvasAssetGenerationTaskIsTerminal(task),
);
/*
canonical content `content`
`prompt` / state
*/
const submitContent = directCodexContentToLegacyContentDto(
input.content,
manifest.assets,
);
const task = createResourceCanvasAssetGenerationTask({
taskId: crypto.randomUUID(),
// 任务绑定它提交时那张占位:成功落点、失败重试都靠这条归属回到同一张占位卡。
draftId,
action,
prompt: input.prompt,
prompt: submitContent.text,
assetName: input.assetName,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
// 参考图只传**当前 manifest 的资产 ID**:原生侧据此读本地正式文件并按当前账号重新
// 建立远端绑定,前端既不传本地路径,也不复用 manifest 里历史账号的远端资源 ID。
referenceAssetIds: resourceCanvasAssetGenerationReferenceIds(
input.references,
submitContent.references,
),
// 失效参考的重试要靠它把「哪一张」说清楚:素材删掉之后清单里已经没有名字了。
referenceLabels: resourceCanvasAssetGenerationReferenceLabels(
input.references,
submitContent.references,
),
// 入口栏目随任务带上(原生 `targetCategory` 可选入参)。前端不拿它当分类真相:
// 落点仍按正式归类后的 section 走。
@@ -8809,11 +8818,10 @@ export default function ProjectDevelopmentView({
draftId,
action,
draft: {
prompt: input.prompt,
content: input.content,
assetName: input.assetName,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
references: input.references,
},
dispatchedImmediately,
};
@@ -8830,7 +8838,7 @@ export default function ProjectDevelopmentView({
// 避免出现未处理的 Promise 拒绝。
void queue.submit(task).catch(() => undefined);
},
[resourceGenerationPlaceholders],
[manifest.assets, resourceGenerationPlaceholders],
);
/**
@@ -9451,24 +9459,28 @@ export default function ProjectDevelopmentView({
return;
}
if (retryTask) {
/*
text + legacy canonical content ****
token `@显示名` chip
*/
const retryContent = legacyContentDtoToContent({
text: retryTask.prompt,
references: resourceCanvasAssetGenerationRetryReferences({
referenceAssetIds: retryTask.referenceAssetIds,
referenceLabels: retryTask.referenceLabels,
assets: manifest.assets,
}),
});
setResourceAssetGenerationPanelReopen({
draftId: placeholder.draftId,
draft: {
prompt: retryTask.prompt,
content: retryContent,
assetName: retryTask.assetName,
aspectRatio: retryTask.aspectRatio,
imageSize: retryTask.imageSize,
/*
ID ****
*/
references: resourceCanvasAssetGenerationRetryReferences({
referenceAssetIds: retryTask.referenceAssetIds,
referenceLabels: retryTask.referenceLabels,
assets: manifest.assets,
}),
},
error: placeholder.error ?? '生成素材失败',
retryOfRequest: true,
@@ -107,11 +107,10 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文
<ResourceCanvasAssetGenerationPanelView
action={uiPrototypeAction}
draft={{
prompt: '主界面与背包页',
content: [{ type: 'input_text', text: '主界面与背包页' }],
assetName: 'AI 生成 UI 设计图',
aspectRatio: '16:9',
imageSize: '1K',
references: [],
}}
error="生成素材失败:远端拒绝"
onSubmit={onSubmit}
@@ -130,7 +129,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'ui-design',
prompt: '主界面与背包页',
content: [{ type: 'input_text', text: '主界面与背包页' }],
assetName: 'AI 生成 UI 设计图',
});
});
@@ -12,6 +12,8 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
type ChatReference,
directCodexContentToPromptText,
legacyContentDtoToContent,
resourceReferenceFromAsset,
} from '../src/features/project-workspace/resourceReferences';
import {
@@ -108,6 +110,14 @@ function resourceReference(
};
}
/** 面板提交载荷里的正文:canonical content 按清单展开 `@显示名`。 */
function submittedText(
input: ResourceCanvasAssetGenerationSubmitInput | undefined,
assets: readonly GameCreationAppAssetManifestEntry[],
): string {
return directCodexContentToPromptText(input?.content ?? [], assets);
}
describe('参考图模型', () => {
test('只有图集不接受用户参考,图标规范与普通生成一致', () => {
expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe(
@@ -249,12 +259,15 @@ describe('生成面板的参考接线', () => {
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
// 重开草稿的提示词与引用是同一次编辑的产物:`@显示名` 已经在正文里。
prompt: '画一只猫 @素材-a',
// 重开草稿就是面板自己那份 canonical content(不是 prompt + references 双轨):
// 这里用 legacy DTO 的翻译函数把「正文 `@显示名` + 引用」还原成同一份 content。
content: legacyContentDtoToContent({
text: '画一只猫 @素材-a',
references,
}),
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => undefined}
@@ -268,10 +281,13 @@ describe('生成面板的参考接线', () => {
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' })],
const submitted = onSubmit.mock.calls[0]?.[0];
expect(submitted?.kind).toBe('image');
// 提交载荷是那一份 canonical content:正文逐字,引用仍是稳定 resourceId。
expect(submittedText(submitted, [imageAsset])).toBe('画一只猫 @素材-a');
expect(submitted?.content).toContainEqual({
type: 'agc_resource_reference',
resourceId: 'asset-a',
});
});
@@ -294,11 +310,13 @@ describe('生成面板的参考接线', () => {
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '按这套界面风格出图标规范 @素材-a',
content: legacyContentDtoToContent({
text: '按这套界面风格出图标规范 @素材-a',
references,
}),
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => undefined}
@@ -313,9 +331,11 @@ describe('生成面板的参考接线', () => {
await user.click(within(panel).getByRole('button', { name: '图标规范' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'icon-spec',
references: [expect.objectContaining({ resourceId: 'asset-a' })],
const submitted = onSubmit.mock.calls[0]?.[0];
expect(submitted?.kind).toBe('icon-spec');
expect(submitted?.content).toContainEqual({
type: 'agc_resource_reference',
resourceId: 'asset-a',
});
});
@@ -404,11 +424,10 @@ describe('生成面板的参考接线', () => {
action={spritesheetAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: ' 金币\n\n宝箱\t钥匙 ',
content: [{ type: 'input_text', text: ' 金币\n\n宝箱\t钥匙 ' }],
assetName: '图标素材',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={onSubmit}
onClose={() => undefined}
@@ -442,32 +461,33 @@ describe('生成面板的参考接线', () => {
within(panel).getByRole('button', { name: '生成图标素材' }),
);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'icon-spritesheet',
prompt: '金币\n\n宝箱\t钥匙',
references: [],
});
const submitted = onSubmit.mock.calls[0]?.[0];
expect(submitted?.kind).toBe('icon-spritesheet');
// 面板逐字交出用户输入(不在这里裁剪空白);收边规范化在出站任务落账那一处,
// 见 `resourceCanvasAssetGenerationTaskModel` 的用例。
expect(submitted?.content).toEqual([
{ type: 'input_text', text: ' 金币\n\n宝箱\t钥匙 ' },
]);
});
test.each([
['😀'.repeat(200), true, '😀'.repeat(200)],
['😀'.repeat(201), false, '😀'.repeat(201)],
['\u0085金币\u0085', true, '金币'],
['\u0085\n ', false, ''],
['\uFEFF', true, '\uFEFF'],
['😀'.repeat(200), true],
['😀'.repeat(201), false],
['\u0085金币\u0085', true],
['\u0085\n ', false],
['\uFEFF', true],
])(
'图集描述按 API 的 Unicode 字符与空白语义校验(%#',
(prompt, accepted, expected) => {
(prompt, accepted) => {
const onSubmit = vi.fn();
render(
<ResourceCanvasAssetGenerationPanelView
action={spritesheetAction}
draft={{
prompt: '',
content: [],
assetName: '图标素材',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={onSubmit}
onClose={() => undefined}
@@ -483,8 +503,11 @@ describe('生成面板的参考接线', () => {
expect(button.disabled).toBe(!accepted);
fireEvent.submit(input.closest('form')!);
if (accepted) {
// 面板不只管「能不能提交」:交出去的还是用户原样输入的那份 content。
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ prompt: expected }),
expect.objectContaining({
content: [{ type: 'input_text', text: prompt }],
}),
);
} else {
expect(onSubmit).not.toHaveBeenCalled();
@@ -506,11 +529,10 @@ describe('生成面板的参考接线', () => {
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
content: [],
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => undefined}
@@ -535,11 +557,10 @@ describe('生成面板的参考接线', () => {
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '',
content: [],
assetName: '猫',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}}
onSubmit={vi.fn()}
onClose={() => undefined}
@@ -594,13 +615,17 @@ describe('生成面板的参考接线', () => {
action={uiPrototypeAction}
assets={[asset('asset-a', 'image/png', 'assets/a.png')]}
draft={{
prompt: '主界面',
// 五条参考里只有 asset-a 还在清单:草稿自己的 content 不因此被裁,
// 计数按 content 里的引用节点算,超限照样挡住提交。
content: legacyContentDtoToContent({
text: '主界面',
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
resourceReference(`asset-${id}`, `素材 ${id}`),
),
}),
assetName: 'UI',
aspectRatio: '16:9',
imageSize: '1K',
references: ['a', 'b', 'c', 'd', 'e'].map((id) =>
resourceReference(`asset-${id}`, `素材 ${id}`),
),
}}
onSubmit={onSubmit}
onClose={() => undefined}
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import {
createResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationPromptText,
type ResourceCanvasAssetGenerationTask,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
import {
@@ -34,6 +35,13 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = {
writesIconSpecReference: false,
};
const spritesheetAction: ResourceCanvasAssetToolAction = {
...uiPrototypeAction,
id: 'generate-icon-spritesheet',
label: '生成图标素材',
assetKind: 'icon-spritesheet',
};
function task(
patch: Partial<ResourceCanvasAssetGenerationTask> & { taskId: string },
): ResourceCanvasAssetGenerationTask {
@@ -515,3 +523,43 @@ describe('「生成任务」侧栏', () => {
).toHaveLength(4);
});
});
describe('出站提示词规范化', () => {
test('任务落账时按动作类型收掉首尾空白:图集用 Unicode 口径,其余用 JS 口径', () => {
// 图集:Rust `str::trim` 认 NELU+0085)为空白;JS `trim` 不认,所以两条必须分开。
expect(
resourceCanvasAssetGenerationPromptText(
spritesheetAction,
'\u0085金币\u0085',
),
).toBe('金币');
expect(
resourceCanvasAssetGenerationPromptText(spritesheetAction, '\uFEFF金币'),
).toBe('\uFEFF金币');
// 其余入口沿用 JS 口径:NEL 不是空白,不会被收掉。
expect(
resourceCanvasAssetGenerationPromptText(
uiPrototypeAction,
'\u0085金币\u0085',
),
).toBe('\u0085金币\u0085');
expect(
resourceCanvasAssetGenerationPromptText(uiPrototypeAction, ' 主界面 '),
).toBe('主界面');
});
test('createResourceCanvasAssetGenerationTask 把这份规范化落到账本提示词上', () => {
const task = createResourceCanvasAssetGenerationTask({
taskId: 't-trim',
action: uiPrototypeAction,
prompt: ' 主界面与背包页 ',
assetName: '主界面设计图',
aspectRatio: '16:9',
imageSize: '1K',
outputPath: null,
projectId: 'project-1',
nowMillis: 1_000,
});
expect(task.prompt).toBe('主界面与背包页');
});
});
@@ -12,7 +12,6 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import {
projectHasIconSpecReference,
@@ -27,6 +26,7 @@ import {
resourceCanvasBottomToolActions,
} from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
import {
generationPromptText,
typeGenerationPrompt,
@@ -377,11 +377,10 @@ describe('ResourceCanvasAssetGenerationPanelView', () => {
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'icon-spec',
prompt: '像素月光厨房的统一视觉规范',
content: [{ type: 'input_text', text: '像素月光厨房的统一视觉规范' }],
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
references: [],
}),
);
});
@@ -411,11 +410,10 @@ describe('ResourceCanvasAssetGenerationPanelView', () => {
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
kind: 'ui-design',
prompt: '横屏单屏界面',
content: [{ type: 'input_text', text: '横屏单屏界面' }],
assetName: 'AI 生成 UI 设计图',
aspectRatio: '9:16',
imageSize: '2K',
references: [],
}),
);
});
@@ -443,21 +441,19 @@ describe('ResourceCanvasAssetGenerationPanelView', () => {
// 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。
const first = onSubmit.mock.calls[0]?.[0] as {
prompt: string;
content: DirectCodexUserContentPart[];
assetName: string;
aspectRatio: string;
imageSize: string;
references: ChatReference[];
};
render(
<ResourceCanvasAssetGenerationPanelView
action={action}
draft={{
prompt: first.prompt,
content: first.content,
assetName: first.assetName,
aspectRatio: first.aspectRatio,
imageSize: first.imageSize,
references: first.references,
}}
error="图片比例不受支持:4:3"
onSubmit={onSubmit}
@@ -10,8 +10,10 @@ 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 { directCodexContentToPromptText } from '../src/features/project-workspace/resourceReferences';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart';
import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils';
afterEach(cleanup);
@@ -101,13 +103,19 @@ describe('生成浮层里的引用选择(真实组件,不做 props mock',
await user.click(within(panel).getByRole('button', { name: '生成图片' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const submitted = onSubmit.mock.calls[0]?.[0] as {
prompt: string;
references: { resourceId: string }[];
content: DirectCodexUserContentPart[];
};
// 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。
expect(directCodexContentToPromptText(submitted.content, assets)).toContain(
'画一只猫',
);
expect(
submitted.references.map((reference) => reference.resourceId),
).toEqual(['asset-a']);
expect(submitted.prompt).toContain('画一只猫');
submitted.content.some(
(part) =>
part.type === 'agc_resource_reference' &&
part.resourceId === 'asset-a',
),
).toBe(true);
});
test('搜索能收窄候选,键盘也能完成选择', async () => {
@@ -505,7 +505,8 @@ describe('ResourceReferenceInput', () => {
});
const draft = onChange.mock.calls.at(-1)?.[0];
// canonical content 是编辑器内容的逐字投影:picker 在每个 chip 后插入的分隔空格
// 也原样进内容,所以派生文本是 `@hero @enemy`(引用本身仍由稳定 resourceId 表达)。
// 也原样进内容(前端不做裁剪),所以派生文本是 `@hero @enemy `,结尾那个空格照样留着;
// 引用本身仍由稳定 resourceId 表达。
const heroPart = chatReferenceToContentPart(
resourceReferenceFromAsset(assets[0]!, 'asset-picker'),
);
@@ -518,7 +519,7 @@ describe('ResourceReferenceInput', () => {
enemyPart,
{ type: 'input_text', text: ' ' },
]);
expect(draftText(draft)).toBe('@hero @enemy');
expect(draftText(draft)).toBe('@hero @enemy ');
expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']);
expect(
document.querySelector('[data-resource-reference-id="hero"]'),
@@ -827,9 +828,9 @@ describe('ResourceReferenceInput', () => {
const deleteButton = screen.getByRole('button', { name: '移除引用 hero' });
expect(chip?.contains(deleteButton)).toBe(true);
// 草稿 text 投影会 trim,但提交用的 content 仍保留引用节点后的分隔空格;
// 草稿 text 是 content 的逐字投影,前端不裁剪:引用节点后的分隔空格照样留在文本里
// 结构化引用完整保留,后端按整条消息判断是否有有效内容。
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero');
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero ');
expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']);
// 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。
@@ -840,7 +841,9 @@ describe('ResourceReferenceInput', () => {
document.querySelector('[data-resource-reference-id="hero"]'),
).toBeNull();
expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0);
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('');
// 引用节点删掉了,只剩插入时补的那个分隔空格;前端不裁剪,草稿文本就是「一个空格」,
// 有效输入由整条 content 判空,不在派生文本上做空白处理。
expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe(' ');
});
test('exposes the current-version and all-canvas scopes as the only two tabs', () => {
@@ -62,3 +62,4 @@
- Planning 提交与 legacy 消费方通过单向 `directCodexContentToLegacyContentDto` 取文本,不回写编辑器。
- 润色回写(`applyPolishedTextToRoot`)对引用与附件用同一套 token 扫描(`draftScanToken`):token 存活则原位换回真 part,被改写的 part 末尾补位;附件不再丢出 canonical content。
- 遗留 TODO:润色服务整体改写文本、连 token 一起删掉时,前端无法反推原位置,只能末尾补位;需要精确恢复时由产品补带位置信息的润色协议。
- 收口扩展(2026-09-21,栏目画布图片类生成浮层):面板 state、重开草稿 `ResourceCanvasAssetGenerationPanelDraft`、重试身份判据与提交载荷全部只有一份 `content[]``directCodexContentToLegacyContentDto` 只在任务落账这一个出站边界派生,账本回草稿走同一套 token 反解析(`legacyContentDtoToContent`)。token 扫描实现从 `ResourceReferenceInput` 提到 `resourceReferences`,润色回写与 legacy 翻译共用一份;无从解析的资源引用合成 `kind: unknown` 占位而不再被静默丢弃。详见 `decision-log.md` 同名条目。
@@ -9285,3 +9285,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``.../features/resource-canvas/resourceCanvasFocusModel.ts``tests/{projectResourceLiveIntegration,resourceCanvasQuickEditDraft,resourceCanvasFloatingDismiss}.test.tsx`、PRD §3.10。
- 验证:定向 `projectResourceLiveIntegration`(32 条,三条断言面板留在失败态的用例按新口径改写为「重开面板再重试,身份不变」)、`resourceCanvasQuickEditDraft`10 条)、`resourceCanvasFloatingDismiss`18 条)全绿;`npm --prefix apps/ai-game-creator-shell run typecheck` 通过。
- 合并前复核(2026-09-21):合并 master 后按**仓库根**跑全量 `npx vitest run`,**373 个测试文件全过、4512 通过 / 34 跳过 / 0 失败**PR #419 显示 `No Conflicts`。真实客户端观感与远程 CI 未复验(后者按用户要求不追,runner/镜像问题见 Issue #431)。
## 2026-09-21 画布生成面板:重开草稿、重试身份与提交载荷全部收敛到一份 canonical content
- 背景:DirectProject composer 收口到 `content[]` 之后,栏目画布的图片类生成浮层仍留着第二套形状——面板里 `prompt` + `references` 两份 state,重开草稿 `ResourceCanvasAssetGenerationPanelDraft` 与重试身份 `boundRequestRef` 也是 `prompt + 参考身份``directCodexContentToLegacyContentDto` 因此在面板渲染、关闭草稿、重试判据、宿主入队四个边界各算一遍;同时草稿用「正文 + 引用列表」两条线重建编辑器输入,`@显示名` 与引用 chip 会重复成 `@素材-a@素材-a`
- 决策(一份事实源):图片类生成面板的 state、重开草稿、重试身份判据与 `ResourceCanvasAssetGenerationSubmitInput` 统一只有 `content[]`(外加素材名 / 比例 / 尺寸三个非文本参数)。重试身份改用 `directCodexContentKey` 做粒度无关的内容指纹比对(合并相邻 `input_text`、丢空串),不再单独冻结 `referenceIds` + `prompt``resourceCanvasAssetGenerationReferenceIdsMatch` 随之删除。
- 决策(DTO 只在出站边界):`directCodexContentToLegacyContentDto``text` / `references` 只在**任务落账**`createResourceCanvasAssetGenerationTask`)那一处派生,面板与宿主 state 不再持有该 DTO 的第二份形状;账本回到草稿的反向翻译集中在宿主重开路径的 `legacyContentDtoToContent`
- 决策(反解析只有一条):legacy「text + references」→ content 走与润色回写同一套 token 扫描(`@显示名` / `$名称` / `@附件名``buildContentFromTextTokens`),命中的位置换回真 part、文本里找不到的补末尾。`directCodexContentToLegacyContentDto` 遇到 manifest 里已不存在的资源引用时合成 `kind: unknown` 的占位引用而**不再丢弃**,让「已不在当前项目」的判据照样能触发。
- 决策(空白口径):`directCodexContentToPromptText` 逐字投影、不再 `trim`(前端只在整条 content 上判空);出站提示词的收边规范化收敛成一个共享函数 `resourceCanvasAssetGenerationPromptText`,面板校验与任务落账共用,图集走 Unicode White_Space、其余走 JS 口径。
- 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}``apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。
- 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding``git diff --check` 通过。未复核真实客户端观感。