From f138f648e7844521b83bf65cd36dac240923a386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 12:12:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E6=8F=92=E5=85=A5=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=87=BA=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E8=90=BD=E4=B8=8B=E6=96=87=E6=9C=AC=E5=BD=A2?= =?UTF-8?q?=E6=80=81=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BB=80=E4=B9=88=E9=83=BD?= =?UTF-8?q?=E4=B8=8D=E6=8F=92=20resourceReferences.ts=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20contentPartToken=EF=BC=9Apart=20=E2=86=92=20=E6=AD=A3?= =?UTF-8?q?=E6=96=87=20token=20=E7=9A=84=E9=80=90=E5=AD=97=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=EF=BC=8C=E4=B8=8D=E8=A1=A5=E7=99=BD=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E8=A3=81=E5=89=AA=EF=BC=88=E8=A1=A5=E7=99=BD=E5=8F=AA=E5=9C=A8?= =?UTF-8?q?=20joinMentionText=20=E6=8B=BC=E6=95=B4=E6=AE=B5=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=97=B6=E5=8A=A0=EF=BC=89=EF=BC=9BdirectCodexContent?= =?UTF-8?q?ToPromptText=20=E4=B8=8E=E6=96=B0=E5=AF=BC=E5=87=BA=20contentPa?= =?UTF-8?q?rtText=20=E9=83=BD=E8=B5=B0=E8=BF=99=E4=B8=80=E4=BB=BD=20conten?= =?UTF-8?q?tPartText=20=E5=B0=B1=E6=98=AF=E5=8D=95=E4=B8=AA=E9=9D=9E?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20part=20=E7=9A=84=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=BD=A2=E6=80=81=EF=BC=9A=E8=B5=84=E6=BA=90=E8=90=BD=20@resou?= =?UTF-8?q?rceId=E3=80=81Skill=20=E8=90=BD=20$=E5=90=8D=E7=A7=B0=E3=80=81?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E8=90=BD=20@=E9=99=84=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E3=80=81=E8=BF=90=E8=A1=8C=E5=8C=BA=E5=9F=9F=E8=90=BD=20@?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=EF=BC=8C=E6=96=87=E6=9C=AC=20part=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20null=20$insertContentAtSelection=20?= =?UTF-8?q?=E7=9A=84=E5=85=9C=E5=BA=95=E9=93=BE=E6=94=B9=E6=88=90=20provid?= =?UTF-8?q?er=20=E7=9A=84=20mentionToken=20=E4=B9=8B=E5=A4=96=E5=86=8D?= =?UTF-8?q?=E6=8E=A5=20contentPartText=EF=BC=8C=E8=A7=A3=E6=9E=90=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E5=BC=95=E7=94=A8=E4=B9=9F=E4=B8=80=E5=AE=9A=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=AD=A3=E5=B8=B8=E6=96=87=E6=9C=AC=E8=90=BD?= =?UTF-8?q?=E5=88=B0=E5=85=89=E6=A0=87=E5=A4=84=EF=BC=8C=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E3=80=8C=E4=B8=A4=E6=9D=A1=E9=83=BD=E7=AD=94=E4=B8=8D=E5=87=BA?= =?UTF-8?q?=E6=9D=A5=E5=B0=B1=E4=BB=80=E4=B9=88=E9=83=BD=E4=B8=8D=E6=8F=92?= =?UTF-8?q?=E3=80=8D=E8=BF=99=E6=9D=A1=E8=BF=9D=E8=83=8C=E3=80=8C=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E5=86=85=E5=AE=B9=E9=80=90=E5=AD=97=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E5=88=86=E6=94=AF=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E8=A6=86=E7=9B=96=E5=9B=9B=E7=B1=BB=20part?= =?UTF-8?q?=20=E7=9A=84=E6=96=87=E6=9C=AC=E5=BD=A2=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20part=20=E8=BF=94=E5=9B=9E=20null=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BE=93=E5=85=A5=E5=8C=BA=E9=9B=86=E6=88=90?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=88provider=20=E7=9A=84=20toReference?= =?UTF-8?q?=20=E4=B8=8E=20mentionToken=20=E9=83=BD=E7=AD=94=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E6=97=B6=E7=B2=98=E8=B4=B4=E4=BB=8D=E6=8C=89?= =?UTF-8?q?=20@resourceId=20=E8=90=BD=E4=B8=8B=EF=BC=89=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=85=9C=E5=BA=95=E5=8D=B3=E5=8F=98=E7=BA=A2=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20AGC=20=E8=81=8A=E5=A4=A9=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E=E7=9A=84?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E5=B0=8F=E8=8A=82=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92=E8=A1=A5?= =?UTF-8?q?=E8=AE=B0=E6=9C=AC=E8=BD=AE=E8=AF=84=E5=AE=A1=E7=9A=84=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ResourceReferenceInput.tsx | 10 ++-- .../project-workspace/resourceReferences.ts | 49 ++++++++++++++----- .../tests/resourceReferenceInput.test.tsx | 35 +++++++++++++ .../tests/resourceReferences.test.ts | 36 ++++++++++++++ .../【实施计划】引用粘贴解析-2026-09-22.md | 10 ++++ .../【功能说明】AGC聊天素材引用-2026-09-08.md | 1 + 6 files changed, 127 insertions(+), 14 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 633b73c26..877747834 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 @@ -64,6 +64,7 @@ import { chatReferenceKey, chatReferenceMentionToken, chatReferenceToContentPart, + contentPartText, dedupeChatReferences, joinMentionText, } from './resourceReferences'; @@ -323,8 +324,9 @@ function $selectionOrRootEnd() { * 文本 part 的 `\n` 落成真正的段落分隔(与编辑器默认的纯文本粘贴同一形状),引用 part 落成 * chip;不加任何补白,token 原位替换、token 之外的每个字符照原样保留。 * - * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本, - * 绝不静默丢掉——粘贴进来的内容一个字符都不会凭空消失。 + * 认不出的引用 part(provider 的 `toReference` 解析不出来)退回它的 token 文本;连 provider 的 + * `mentionToken` 都答不出来时退到 `contentPartText` 的通用文本形态。两条兜底合起来保证 + * 「粘贴进来的内容一个字符都不会凭空消失」,这个分支不存在什么都不插的出路。 * * 返回「这次到底插进去没有」:调用方据此决定要不要接管这次粘贴,插入为空时必须放行 * 编辑器的默认粘贴,否则这段文字两边都不管。 @@ -359,7 +361,9 @@ function $insertContentAtSelection( return; } // 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。 - const token = mentionTokenFromPart(providers, part); + // provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。 + const token = + mentionTokenFromPart(providers, part) ?? contentPartText(part); if (token) { $selectionOrRootEnd()?.insertText(token); inserted = true; 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 440e70d6b..adc78ecba 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 @@ -227,17 +227,44 @@ export function directCodexContentToPromptText( content: readonly DirectCodexUserContentPart[], resolveResourceLabel: ResourceLabelResolver, ) { - return joinMentionText(content, (part) => { - if (part.type === 'input_text') return null; - if (part.type === 'agc_attachment_reference') - return `@${normalizeMentionName(part.name)}`; - if (part.type === 'agc_skill_reference') - return `$${normalizeMentionName(part.name)}`; - if (part.type === 'agc_runtime_region_reference') return `@${part.label}`; - return `@${normalizeMentionName( - resolveResourceLabel(part.resourceId) ?? part.resourceId, - )}`; - }); + return joinMentionText(content, (part) => + contentPartToken(part, resolveResourceLabel), + ); +} + +/** + * 一个 part 在正文文本里的 token(`@显示名` / `$名称` / `@附件名` / `@区域标签`);文本 part + * 没有 token,返回 `null`。 + * + * 逐字返回 token 本身:不带补白、不裁剪——补白是 `joinMentionText` 在拼整段文本时加的, + * 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。 + */ +function contentPartToken( + part: DirectCodexUserContentPart, + resolveResourceLabel: ResourceLabelResolver, +): string | null { + if (part.type === 'input_text') return null; + if (part.type === 'agc_attachment_reference') + return `@${normalizeMentionName(part.name)}`; + if (part.type === 'agc_skill_reference') + return `$${normalizeMentionName(part.name)}`; + if (part.type === 'agc_runtime_region_reference') return `@${part.label}`; + return `@${normalizeMentionName( + resolveResourceLabel(part.resourceId) ?? part.resourceId, + )}`; +} + +/** + * 单个非文本 part 的文本形态,就是它的 token(见 `contentPartToken`):资源拿不到显示名时 + * 退回 `resourceId`。文本 part 没有文本形态,返回 `null`。 + * + * 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去, + * 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。 + */ +export function contentPartText( + part: DirectCodexUserContentPart, +): string | null { + return contentPartToken(part, () => undefined); } /** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */ diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 0fdbd3298..0a62f8041 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -489,6 +489,41 @@ describe('ResourceReferenceInput', () => { expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); }); + test('粘贴命中的引用连 token 都解析不出来时,落下它的文本形态而不是什么都不插', async () => { + const ref = createRef(); + // provider 契约被破坏:lookup 有候选,但 toReference 与 mentionToken 都答不出来。 + // 这时必须落一段正常文本(资源退回 resourceId),粘贴进来的字不许凭空消失。 + const brokenProvider: ReferenceProvider = { + trigger: '@', + fuzzy_lookup: () => [], + lookup: () => [ + resourceReferenceFromAsset( + asset('ghost-asset', 'character', 'image/png', 'assets/幽灵.png'), + 'asset-picker', + ), + ], + toReference: () => null, + refresh: (reference) => reference, + mentionToken: () => null, + }; + render( + , + ); + + pasteComposerText('看 @幽灵 一眼'); + + await waitFor(() => { + expect(draftText(ref.current?.getDraft())).toBe('看 @ghost-asset 一眼'); + }); + expect(draftResourceIds(ref.current?.getDraft())).toEqual([]); + }); + test('粘贴未命中的 token:整段按字面落进正文,不接管、不提示', async () => { const ref = createRef(); render( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index 3ed6d96c5..12c1c1abb 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -7,6 +7,7 @@ import { chatComposerDraftToDirectCodexUserItem, type ChatReference, chatReferenceMentionToken, + contentPartText, directCodexContentToPromptText, hasMeaningfulDirectCodexContent, normalizeMentionName, @@ -275,6 +276,41 @@ describe('粘贴文本反解析', () => { expect(buildContentFromPastedText('@ 只有裸符号', [nameless])).toBeNull(); }); + it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => { + // 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。 + const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({ + type: 'agc_runtime_region_reference', + label, + runId: null, + versionId: null, + elementTag: null, + elementRole: null, + text: null, + width: null, + height: null, + resourceIds: [], + }); + expect( + contentPartText({ type: 'agc_resource_reference', resourceId: 'hero' }), + ).toBe('@hero'); + expect( + contentPartText({ type: 'agc_skill_reference', name: 'image gen' }), + ).toBe('$image-gen'); + expect( + contentPartText({ + type: 'agc_attachment_reference', + name: 'brief v2.md', + mediaType: 'text/markdown', + size: 1, + localPath: 'notes/brief v2.md', + status: 'imported', + }), + ).toBe('@brief-v2.md'); + expect(contentPartText(runtimeRegionPart('主画面'))).toBe('@主画面'); + // 文本 part 没有「文本形态」,它自己就是文本。 + expect(contentPartText({ type: 'input_text', text: '先看' })).toBeNull(); + }); + it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { const content: DirectCodexUserContentPart[] = [ { type: 'input_text', text: '看 ' }, diff --git a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md index 966cab5fb..bb8f0c2c1 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -50,3 +50,13 @@ - 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。 - 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 - 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 + +## 追加执行状态(2026-09-23 第二轮自动评审):空名字与插入兜底收口 + +第二轮自动评审的四个问题逐个提交处理,落点都在 `apps/ai-game-creator-shell`: + +- 显示名不再可能为空:`resourceDisplayName` 在文件名词干为空(`.env` / `.gitignore`)时回退 `asset.id`,回退值同样过 `normalizeMentionName`(提交 `4e90465c4`)。这条校正了上一节「不做兜底」的写法,决策记录与功能说明同步。 +- 粘贴解析跳过退化 token:`buildContentFromPastedText` 组装候选时跳过 `token.length <= 1` 的条目,裸 `@` / `$` 不再认领正文里的触发符(提交 `3f9c698a6`)。 +- 粘贴接管改成「插入真的发生之后」才 `preventDefault`:`$insertContentAtSelection` 返回「插进去没有」,插入为空时放行默认粘贴;编辑器已在更新中(回调被排队)时按原口径先接管(提交 `3391ecf7d`)。 +- 插入兜底不再留空:provider 的 `toReference` 与 `mentionToken` 都答不出来的 part 退到新增的 `contentPartText`(通用文本形态,资源落 `@resourceId`)。此前这一支会什么都不插,是「粘贴内容逐字保留」唯一的例外分支;现在没有例外。新增单测覆盖四类 part 的文本形态,新增输入区集成用例证明被破坏的 provider 契约下这段粘贴仍按文本落下,去掉兜底即变红。 +- 归一化撞名(`hero v2` 与 `hero-v2` 折成同一个 token)只在解析侧兜住、候选菜单不提示冲突,这一条涉及「哪些素材能被 @ 到」的产品取舍,未改代码,留给下一轮决定。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 3bbd8ac33..ea9048a42 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -40,6 +40,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 名字为空的引用(token 只剩一个裸触发符)同样不参与解析:裸 `@` / `$` 会在正文里匹配到任何一处触发符,认下来就是把无关文字错认成引用。显示名一侧另有兜底(见下节「词干为空的素材名回退 `asset.id`」),这条是解析侧的最后一道。 - 附件与运行画面区域的 token(`@附件名` / `@区域标签`)不参与解析——这两类引用没有候选,身份来自文件与运行记录,纯文本重建不出来,所以粘贴时按文本保留。 - 只有真的解析出引用、并且这一整段真的插进了正文时才接管粘贴:同 namespace 的 Lexical 负载(跨输入区复制芯片)、不含 token 的纯文本、图片文件粘贴都继续走编辑器默认导入,现有行为不变。取不到选区时插入为空,这时也放行默认粘贴,粘贴的文字不会「两边都不管」。接管时整段文本在一次编辑更新内插入,一次 Ctrl+Z 就是一次撤销。 +- 插入那一刻解析不出引用时不留空:先退回 provider 的 token(`@显示名` / `$名称`),provider 连 token 都答不出来(契约被破坏)时退到 `contentPartText` 的通用文本形态——资源拿不到显示名就落 `@resourceId`,Skill / 附件 / 运行区域各落自己的名字或标签。所以「粘贴进来的内容一个字符都不会凭空消失」在这条链路上没有例外分支。 - 候选来源按「模糊 / 精确」分两个口:菜单走 `fuzzyLookup(query)`(包含匹配 + 截断到候选上限),粘贴解析走 `lookup()`(就绪的全量候选,不模糊、不截断)。 - Skill 目录是应用级异步读取,仍然只在用户第一次敲出 `$` 时读:冷启动时粘贴 `$名称` 就按字面文本保留(粘贴不会为了解析去提前读盘,也不会等待目录),用户敲过一次 `$` 之后粘贴即可重建芯片。