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 9e6bafad2..0681829fe 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 @@ -269,7 +269,28 @@ export function readResourceReferenceDraft( type DraftBuildSegment = | { kind: 'text'; text: string } - | { kind: 'reference'; reference: ChatReference }; + | { 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); @@ -294,23 +315,21 @@ function findDraftMentionToken(line: string, token: string, from: number) { } /** - * 把「文本 + 引用列表」这对外部表示切成编辑区要写的内容。 + * 把「润色回包文本 + 待恢复 part」切成编辑区要写的内容。 * * 不变量:写入编辑器后,编辑器读回的草稿就是唯一当前值。`collectDraftParts` * 会把每个 chip 读成一段 `@显示名` / `$skill-name` 文本,所以 chip 只能内联嵌在文本里同名 token 的位置上, * 不能另起一段堆在末尾;编辑器本身是唯一 authority,不再通过 props 比较后反复重建。 * - * 文本里已经没有对应 token 的引用(例如 AI 润色整体改写了文本)内联补到最后一段末尾, - * 引用不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。 + * 文本里已经没有对应 token 的 part(例如 AI 润色整体改写了文本)内联补到最后一段末尾, + * 引用与附件不会凭空消失;这只发生在一次明确的初始草稿/润色写入中。 */ function buildDraftSegments( value: string, - references: ChatReference[], + candidates: readonly DraftBuildCandidate[], ): DraftBuildSegment[][] { - const pending = references.map((reference) => ({ - reference, - token: - reference.type === 'skill' ? `$${reference.name}` : `@${reference.label}`, + const pending = candidates.map((candidate) => ({ + ...candidate, used: false, })); const lines: DraftBuildSegment[][] = []; @@ -332,7 +351,7 @@ function buildDraftSegments( segments.push({ kind: 'text', text: line.slice(cursor, match.index) }); } match.item.used = true; - segments.push({ kind: 'reference', reference: match.item.reference }); + segments.push({ kind: 'part', part: match.item.part }); cursor = match.index + match.item.token.length; } if (cursor < line.length) { @@ -342,12 +361,7 @@ function buildDraftSegments( } const orphans = pending .filter((item) => !item.used) - .map( - (item): DraftBuildSegment => ({ - kind: 'reference', - reference: item.reference, - }), - ); + .map((item): DraftBuildSegment => ({ kind: 'part', part: item.part })); if (orphans.length > 0) { const lastLine = lines.at(-1); if (!lastLine) return [orphans]; @@ -360,37 +374,46 @@ function buildDraftSegments( } /** - * 润色回写仍按文本中的 token 精确恢复资源/Skill/runtime chip;附件没有可安全反解析的 - * 稳定 token,因此保留原 attachment part 并放在新文本之后。 - * TODO: 提出“润色精确恢复附件原位置”的产品能力后,再把附件位置纳入协议,不猜字符串。 + * 润色回写:回包文本里还剩哪个 token,就在那个位置换回真的 part——引用与附件同一套扫描 + * 口径(`draftScanToken`),所以润色改过的那段文字里如果还留着 `@显示名` / `$名称` / + * `@附件名`,chip 原位复活;整体被改写的那些 part 补在末尾,不丢。 + * + * TODO: 润色服务整体改写文本、把 `@名称` token 也删掉时,前端无法反推原位置,只能补在末尾。 + * 需要“润色精确恢复引用/附件原位置”时,由产品补一个带位置信息的润色协议,不在前端猜字符串。 */ function applyPolishedTextToRoot( value: string, current: DraftProjection, assetsById: ReadonlyMap, ) { - const lines = value.split(/\r?\n/u); - const rebuiltContent: DirectCodexUserContentPart[] = []; - buildDraftSegments(value, current.references).forEach( - (segments, lineIndex) => { - segments.forEach((segment) => { - if (segment.kind === 'text') { - if (segment.text) - rebuiltContent.push({ type: 'input_text', text: segment.text }); - return; - } - rebuiltContent.push(chatReferenceToContentPart(segment.reference)); - }); - if (lineIndex < lines.length - 1) { - rebuiltContent.push({ type: 'input_text', text: '\n' }); - } - }, - ); - rebuiltContent.push( - ...current.content.filter( - (part) => part.type === 'agc_attachment_reference', + const resourceLabels = new Map( + current.references.flatMap((reference) => + reference.type === 'resource' + ? [[reference.resourceId, reference.label] as const] + : [], ), ); + // 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描, + // 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。 + const candidates = current.content.flatMap((part) => { + const token = draftScanToken(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); } 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 0071f63d3..279ff147a 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,10 +127,6 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) { ); } -export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { - content: [], -}; - /** canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。 */ export function directCodexContentToPromptText( content: readonly DirectCodexUserContentPart[], diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 5b2fcd260..f6e170485 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -346,7 +346,7 @@ describe('ResourceReferenceInput', () => { expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); }); - test('润色回写保留原附件 part,不把 attachment chip 丢出 canonical content', async () => { + test('润色改写掉 `@附件名` token 时,附件 part 补在末尾而不是丢掉', async () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const attachment = { @@ -383,11 +383,62 @@ describe('ResourceReferenceInput', () => { const content = onChange.mock.calls.at(-1)?.[0].content; expect(content).toEqual([ chatReferenceToContentPart(reference), - { type: 'input_text', text: ' 润色后的需求' }, + // 末尾补位用的分隔空格与相邻文本在 Lexical 里合并成一个 TextNode。 + { type: 'input_text', text: ' 润色后的需求 ' }, attachment, ]); }); + test('润色回包还留着 `@名称` token 时,引用与附件都在原位换回真 part', async () => { + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); + const attachment = { + type: 'agc_attachment_reference' as const, + name: '参考图.png', + mediaType: 'image/png', + size: 12, + localPath: 'assets/reference.png', + status: 'imported', + }; + const composerRef = createRef(); + render( + <> + + + , + ); + await settleComposer(); + + fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); + await settleComposer(); + const content = onChange.mock.calls.at(-1)?.[0].content; + expect(content).toEqual([ + chatReferenceToContentPart(reference), + { type: 'input_text', text: ' ' }, + attachment, + { type: 'input_text', text: ' 润色后的需求' }, + ]); + }); + test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup();