From a11cdf13d7c4acbb304421fa56bb733cd013fb31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 22:56:32 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=94=9F=E6=88=90=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=87=8D=E5=BC=80=E8=8D=89=E7=A8=BF=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=BA=AB=E4=BB=BD=E6=94=B6=E6=95=9B=E5=88=B0=20canoni?= =?UTF-8?q?cal=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成面板 state、重开草稿、重试身份与提交载荷统一为一份 content[] 重试身份改用 directCodexContentKey 内容指纹比对并删除 referenceIdsMatch directCodexContentToLegacyContentDto 只在任务落账的出站边界派生 账本回到草稿的反向翻译收敛为 legacyContentDtoToContent 单一实现 token 扫描从 ResourceReferenceInput 提到 resourceReferences 并两处共用 无法解析的资源引用合成 unknown 占位而不再静默丢弃 出站提示词规范化收敛为 resourceCanvasAssetGenerationPromptText 供两处共用 directCodexContentToPromptText 去掉 trim,空白只在整条 content 上判定 同步决策记录与 canonical content 里程碑落地结果 --- .../ResourceReferenceInput.tsx | 129 +--------- .../project-workspace/resourceReferences.ts | 225 +++++++++++++++++- ...ResourceCanvasAssetGenerationPanelView.tsx | 157 ++++++------ ...urceCanvasAssetGenerationReferenceModel.ts | 18 -- .../resourceCanvasAssetGenerationTaskModel.ts | 19 +- .../src/view/project-development/index.tsx | 48 ++-- ...vasAssetGenerationBackgroundClose.test.tsx | 5 +- ...ceCanvasAssetGenerationReferences.test.tsx | 97 +++++--- ...ceCanvasAssetGenerationTasksPanel.test.tsx | 48 ++++ .../resourceCanvasBottomToolbar.test.tsx | 14 +- ...urceCanvasGenerationFloatingPanel.test.tsx | 18 +- .../tests/resourceReferenceInput.test.tsx | 13 +- ...ct composer canonical content闭环-2026-09-21.md | 1 + .../shared-memory/decision-log.md | 10 + 14 files changed, 505 insertions(+), 297 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 0681829fe..1d9adf5e6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -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 | 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( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index 279ff147a..00cbea707 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -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 | 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') { 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 8e49a1463..9e7abb194 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 @@ -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( - draft?.references ?? [], + /** + * 面板的唯一草稿事实源:`content[]`。 + * + * 面板**只**存这一份 canonical 输入:重开草稿、卸载时交回的草稿、重试身份判据与提交载荷 + * 都是它;提示词文本与引用列表只是渲染期的单向投影(下面的 + * `directCodexContentToLegacyContentDto`),用来做字数、计数与失效参考的判据。 + */ + const [content, setContent] = useState(() => + draft ? [...draft.content] : [], ); + /** + * 润色要作用在**编辑器里的那份 content** 上,不能只改面板的派生文本。 + * + * 挂引用输入区时走它的 `replaceText`:回包文本里的 `@显示名` 原位换回真引用 chip; + * 没有引用输入区的纯文本分支没有编辑器,直接写回一条 `input_text`。 + */ + const promptInputRef = useRef(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({ - 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_Space;JS 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(() => [ - ...(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({ */
setPrompt(event.currentTarget.value)} + onChange={(event) => + setContent([ + { type: 'input_text', text: event.currentTarget.value }, + ]) + } /> )}
@@ -577,7 +588,7 @@ export function ResourceCanvasAssetGenerationPanelView({ subject={`素材生成提示词(${action.label})`} editKind="image-reference" prompt={prompt} - applyPrompt={setPrompt} + applyPrompt={applyPolishedPrompt} /> {referenceEnabled && referenceLimit > 0 ? (

resourceId === current[index]) - ); -} - /** * 用任务上冻结的参考身份重建重试草稿:**失效参考保留原 ID 与原名,不静默丢弃**。 * 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 31a1957f3..a7df6b4ba 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 @@ -194,6 +194,22 @@ export function resourceCanvasAssetGenerationTaskIsTerminal( return task.status === 'completed' || task.status === 'failed'; } +/** + * 出站提示词:面板校验与任务落账用**同一条**规范化,两端不许各写一份。 + * + * Rust `str::trim` 使用 Unicode White_Space,JS `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, 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 1c3c27ab8..edf2bc0d5 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 @@ -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, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index ecee9c985..a5d24b29b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -107,11 +107,10 @@ 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( 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} diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 5d01d2ac8..c30d6d682 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -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 & { taskId: string }, ): ResourceCanvasAssetGenerationTask { @@ -515,3 +523,43 @@ describe('「生成任务」侧栏', () => { ).toHaveLength(4); }); }); + +describe('出站提示词规范化', () => { + test('任务落账时按动作类型收掉首尾空白:图集用 Unicode 口径,其余用 JS 口径', () => { + // 图集:Rust `str::trim` 认 NEL(U+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('主界面与背包页'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index 7818caca7..552683625 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -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( 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 () => { diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0802340d6..eb06cfcd7 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -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', () => { diff --git a/docs/project-memory/plans/【里程碑】DirectProject composer canonical content闭环-2026-09-21.md b/docs/project-memory/plans/【里程碑】DirectProject composer canonical content闭环-2026-09-21.md index abe3ecafa..27676fa19 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject composer canonical content闭环-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】DirectProject composer canonical content闭环-2026-09-21.md @@ -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` 同名条目。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 486e4f036..15da45d06 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/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` 通过。未复核真实客户端观感。