From 4e90465c4579006021c17ec96054cca51bb79e7c 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 11:53:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E5=9C=A8=E8=AF=8D=E5=B9=B2=E4=B8=BA?= =?UTF-8?q?=E7=A9=BA=E6=97=B6=E5=9B=9E=E9=80=80=20asset.id=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BA=A7=E7=94=9F=E7=A9=BA=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=90=8D=20resourceDisplayName=20=E5=9C=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E5=8E=BB=E6=8E=89=E6=89=A9=E5=B1=95=E5=90=8D=E5=90=8E?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=EF=BC=88`.env`=20/=20`.gitignore`=20?= =?UTF-8?q?=E8=BF=99=E7=B1=BB=E6=95=B4=E5=90=8D=E5=B0=B1=E6=98=AF=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=90=8D=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=89=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20asset.id=EF=BC=8C=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=80=BC=E5=90=8C=E6=A0=B7=E8=BF=87=20normalizeMentionName?= =?UTF-8?q?=EF=BC=9B=E7=A9=BA=E5=90=8D=E5=AD=97=E4=BC=9A=E8=AE=A9=20token?= =?UTF-8?q?=20=E9=80=80=E5=8C=96=E6=88=90=E5=8F=AA=E6=9C=89=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=AC=A6=E7=9A=84=E8=A3=B8=20`@`=EF=BC=8C=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=8F=9C=E5=8D=95=E9=87=8C=E6=98=AF=E7=A9=BA=E8=8A=AF?= =?UTF-8?q?=E7=89=87=E3=80=81=E7=B2=98=E8=B4=B4=E8=A7=A3=E6=9E=90=E8=BF=98?= =?UTF-8?q?=E4=BC=9A=E8=AE=A4=E9=A2=86=E6=AD=A3=E6=96=87=E9=87=8C=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=E4=B8=80=E5=A4=84=E8=A3=B8=20`@`=20normalizeMentionNa?= =?UTF-8?q?me=20=E7=9A=84=E6=B3=A8=E9=87=8A=E6=94=B9=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E5=8F=AA=E5=81=9A=E5=BD=92=E4=B8=80=E5=8C=96=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E5=81=9A=E5=85=9C=E5=BA=95=E3=80=8D=EF=BC=8C=E6=8A=8A=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E7=9A=84=E8=90=BD=E7=82=B9=E5=86=99=E6=98=8E=E5=9C=A8?= =?UTF-8?q?=E5=90=8D=E5=AD=97=E7=9A=84=E6=9D=A5=E6=BA=90=E4=BE=A7=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=EF=BC=9A`.env`=20=E4=B8=8E?= =?UTF-8?q?=20`config/.gitignore`=20=E7=9A=84=E6=98=BE=E7=A4=BA=E5=90=8D?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20asset.id=E3=80=81=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=B8=8D=E5=86=8D=E4=B8=BA=E7=A9=BA=EF=BC=9B?= =?UTF-8?q?=E6=8A=8A=E8=AF=A5=E5=9B=9E=E9=80=80=E5=8E=BB=E6=8E=89=E5=90=8E?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=E5=8F=98=E7=BA=A2=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E8=AE=B0=E5=BD=95=EF=BC=88=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E3=80=8C=E4=B8=8D=E5=81=9A=20resourceId=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E3=80=8D=E4=B8=80=E6=9D=A1=EF=BC=89=E3=80=81AGC=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=B4=A0=E6=9D=90=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E4=B8=8E=E5=BC=95=E7=94=A8=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/resourceReferences.ts | 14 ++++++++++++-- .../tests/resourceReferences.test.ts | 13 +++++++++++++ .../plans/【实施计划】引用粘贴解析-2026-09-22.md | 2 +- docs/project-memory/shared-memory/decision-log.md | 1 + docs/【功能说明】AGC聊天素材引用-2026-09-08.md | 2 +- 5 files changed, 28 insertions(+), 4 deletions(-) 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 fe81b1f3c..bef2cd660 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 @@ -163,7 +163,8 @@ export function resourceLabelResolver( * 文本 `v2`:短名字抢先命中,真正的引用反而变成补在末尾的孤儿。空白折成 `-` 之后 token 自带 * 边界,`@hero` 不会再命中 `@hero-v2`(后一个字符是 `-`,不是空白)。 * - * 不在这里做 `resourceId` 之类的兜底:引用名假定非空,空名字属于上游数据问题,不靠兜底掩盖。 + * 这里只做归一化、不做 `resourceId` 之类的兜底:兜底属于名字的来源侧(例如 + * `resourceDisplayName` 在文件名词干为空时回退 `asset.id`),归一化本身保持是个纯函数。 */ export function normalizeMentionName(value: string) { return value.trim().replace(/\s+/gu, '-'); @@ -626,9 +627,18 @@ export function directCodexContentToLegacyContentDto( }; } +/** + * 素材显示名:文件名去掉扩展名,再过一遍引用名口径(见 `normalizeMentionName`)。 + * + * 整名就是扩展名时(`.env`、`.gitignore`)去扩展名会得到空串,这里回退 `asset.id`: + * 显示名同时是正文里的 token,空名字会退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片, + * 粘贴解析还会拿它认领正文里任何一处裸 `@`(见 `buildContentFromPastedText`)。 + * 与渲染侧「显示名解析不到就用 `resourceId`」是同一口径。 + */ export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { const fileName = asset.localPath.split(/[\\/]/u).pop() ?? asset.id; - return normalizeMentionName(fileName.replace(/\.[^.]+$/u, '')); + const stem = fileName.replace(/\.[^.]+$/u, ''); + return normalizeMentionName(stem || asset.id); } export function resourceReferenceFromAsset( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index ce275ecc3..aee75e632 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -239,6 +239,19 @@ describe('粘贴文本反解析', () => { ]); }); + it('整名就是扩展名(`.env` / `.gitignore`)时显示名回退 asset.id,不留空名', () => { + // 空名字会退化成只有触发符的裸 `@` token:菜单里是一枚空芯片,粘贴解析还会认领正文里 + // 任何一处裸 `@`(见「退化 token 不参与解析」用例)。 + expect(resourceDisplayName(assetEntry('dotenv', '.env'))).toBe('dotenv'); + expect( + resourceDisplayName(assetEntry('gitignore', 'config/.gitignore')), + ).toBe('gitignore'); + expect( + resourceReferenceFromAsset(assetEntry('dotenv', '.env'), 'asset-picker') + .label, + ).toBe('dotenv'); + }); + 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 cc60b645a..966cab5fb 100644 --- a/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md +++ b/docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md @@ -47,6 +47,6 @@ 自动评审留下的唯一破坏性项(`@hero` 与 `@hero v2` 互为前缀时粘贴会多插一枚短名芯片)不改反解析,改为把不变量前移到引用名: - `resourceReferences.ts` 新增共享 `normalizeMentionName(value)`(内部空白折 `-`、裁首尾),素材显示名(`resourceDisplayName`)、Skill 名(目录读入与 `toReference` / `mentionToken`)、附件名(导入映射与 `toReference` / `mentionToken`)与两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)统一过这一份;token 层重复归一化是幂等的。 -- 名称里不再做 `resourceId` 兜底(`resourceDisplayName` 去掉 `|| asset.id`);假定引用名非空。 +- 名称里不做 `resourceId` 兜底这一条按评审校正:词干为空(`.env` / `.gitignore` 这类整名就是扩展名的文件)时 `resourceDisplayName` 回退 `asset.id`,回退值同样过 `normalizeMentionName`;`normalizeMentionName` 自己仍只做归一化。 - 代价与取舍:`hero v2` 与 `hero-v2` 归一化后同名时走既有的「同名多候选按文本保留」;改动前生成的旧文本里的 `@hero v2` 不再解析。 - 验证:`normalizeMentionName` / 显示名 / token 口径单测 + 前缀重叠回归用例(归一化后粘贴只剩正确的那一枚芯片)+ provider 两处用例;把 `normalizeMentionName` 变异成恒等后新增用例全红。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 85f5b4e34..494a01517 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,6 +5,7 @@ - 背景:自动评审发现 `buildContentFromTextTokens` 在前缀重叠时会多插一枚芯片——素材显示名 `hero` 与 `hero v2` 并存时,粘贴 `看 @hero v2 这一版` 得到 `[chip hero]` + `[chip hero-v2]`(短名先按 index 平局抢位,长名成了补到末尾的孤儿)。根因不是匹配算法,而是**引用名自己带空白**:token 的边界规则是「前后为空白或行首行尾」,`@hero␠` 在 `@hero v2` 内部也算一次合法命中。 - 决策:把不变量前移到引用名——`@显示名` / `$名称` / `@附件名` 的名字内部不允许空白,统一经共享 `normalizeMentionName(value)`(内部空白折成 `-`、裁掉首尾)处理。落点是名字的产生处:`resourceDisplayName()`、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`,外加两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)——token 层幂等再折一次,「token 里没有空白」就是不变量本身的性质,不依赖上游数据干净。 - 决策(不兜底):引用名假定非空,不做 `resourceId` 之类的兜底;`resourceDisplayName` 原来的 `|| asset.id` 一并去掉。 +- 校正(2026-09-23,评审项):`resourceDisplayName` 的空名字兜底不能一并去掉——整名就是扩展名时(`.env` / `.gitignore`)去掉扩展名得到空串,显示名成了空串,token 退化成只有触发符的裸 `@`(候选菜单里是空芯片,粘贴解析还会认领正文里任何一处裸 `@`)。改为词干为空时回退 `asset.id`(仍过 `normalizeMentionName`);`normalizeMentionName` 自己没有兜底、只做归一化这条不变。 - 原因:不改反解析是因为粘贴解析与润色回包共用 `buildContentFromTextTokens`,改匹配算法要冒回归润色的风险;而「名字里带空白的 token」本来就无法手敲(候选触发器 `allowWhitespace: false`,空格处菜单就关),显示口径与输入口径早就不一致。折成 `-` 之后 token 自带边界:`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`,不是空白),前缀重叠不可能再发生。 - 影响范围:`apps/ai-game-creator-shell/src/features/project-workspace/{resourceReferences.ts,reference-source/{skillReferenceProvider.ts,attachmentReferenceProvider.ts}}`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexTurnAttachments.ts`、`apps/ai-game-creator-shell/tests/{resourceReferences.test.ts,referenceSourceProviders.test.ts}`、`CONTEXT.md`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`、`docs/project-memory/plans/【实施计划】引用粘贴解析-2026-09-22.md`。 - 代价(已接受):归一化可能撞名(`hero v2` 与 `hero-v2` 同名),走既有的「同名多候选一律按文本保留」——不认错,但两者都成不了芯片;改动前生成的旧文本(历史回合 prompt、旧气泡)里的 `@hero v2` 不再解析,重试 / 润色回填时那条引用会退化成末尾孤儿(内容不丢、位置可能不对)。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 554cae23d..549cb3e06 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -48,7 +48,7 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材, - 归一化落在名字的产生处:`resourceDisplayName()`(素材显示名由文件名派生)、Skill 目录读入与 `toReference` / `mentionToken`、附件导入映射与 `toReference` / `mentionToken`、以及两个 token 投影(`chatReferenceMentionToken`、`directCodexContentToPromptText`)。token 层再折一次是幂等的,因此「token 里绝不会有空白」是这一层的性质,不依赖上游数据干净。 - 为什么必须有这条不变量:token 的边界规则是「前后为空白或行首行尾」。名字里带空白时,`@hero v2` 会被反解析切成 `@hero` + 文本 `v2`,短名字抢先命中,真正的引用反而被当成孤儿补到正文末尾(粘贴回填会多出一枚错芯片)。折成 `-` 之后 token 自带边界,`@hero` 不会命中 `@hero-v2`(后一个字符是 `-`)。 -- 名字里不做 `resourceId` 之类的兜底:引用名假定非空。 +- 词干为空的素材名回退 `asset.id`:整名就是扩展名时(`.env` / `.gitignore`)文件名去掉扩展名会得到空串,空名字会让 token 退化成只有触发符的裸 `@`——候选菜单里是一枚空芯片,粘贴解析还会拿它认领正文里任何一处裸 `@`。所以 `resourceDisplayName()` 在这一种情况下回退 `asset.id`,回退值同样过一遍 `normalizeMentionName`;`normalizeMentionName` 自身仍然只做归一化、不做兜底。 - 已存在的老文本(本次改动前生成的回合 prompt、历史气泡)里的 `@hero v2` 不再解析——粘贴时按字面保留,重试 / 润色回填时该引用会退化成末尾孤儿。内容不丢,位置可能不对。 ## 拖拽引用(2026-09-21)