From fbfc7e5e6212f8e58fc487de157a69979b512224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:06:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E5=80=99=E9=80=89?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E7=9A=84=20key=20=E6=94=B9=E7=94=A8=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=BA=AB=E4=BB=BD=EF=BC=8C=E5=90=8C=E5=90=8D=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E4=B8=8D=E5=86=8D=E6=92=9E=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出 resourceReferences 的 chatReferenceKey(只认稳定身份、不含显示名的身份键),并加注释说明它为什么不能省 - ReferenceMentionOption 由 super(chatReferenceMentionToken(...)) 改为 super(chatReferenceKey(...)):characters/hero.png 与 enemies/hero.png 都展开成 @hero,拿显示 token 当 key 会让 React 撞 key、复用错 DOM,键盘高亮与选中落到另一条候选上 - referenceSourceProviders 用例补一批同名素材:断言两条候选的显示 token 相同、身份键不同 --- .../ResourceReferenceInput.tsx | 5 ++- .../project-workspace/resourceReferences.ts | 8 ++++- .../tests/referenceSourceProviders.test.ts | 31 ++++++++++++++++--- 3 files changed, 37 insertions(+), 7 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 b9adbd8ca..8a9ccfb63 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 @@ -57,6 +57,7 @@ import { type ChatComposerDraft, type ChatReference, chatReferenceDisplayHint, + chatReferenceKey, chatReferenceMentionToken, chatReferenceToContentPart, dedupeChatReferences, @@ -121,7 +122,9 @@ class ReferenceMentionOption extends MenuOption { hint: string; constructor(reference: ChatReference) { - super(chatReferenceMentionToken(reference)); + // key 必须是引用的唯一身份:显示 token(`@显示名`)不唯一,同名素材会撞 key 并让 + // React 复用错 DOM、键盘高亮落到别的候选上。显示 token 只留给 `label`。 + super(chatReferenceKey(reference)); this.reference = reference; this.label = chatReferenceMentionToken(reference); this.hint = chatReferenceDisplayHint(reference); 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 be37af508..85f814c3f 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 @@ -788,7 +788,13 @@ function runtimeRegionReferenceDiscriminators( }:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`; } -function chatReferenceKey(reference: ChatReference) { +/** + * 引用的身份键:只认稳定身份,**不含显示名**。 + * + * 同一个显示名可以来自两条不同引用(`characters/hero.png` 与 `enemies/hero.png` 都展开成 + * `@hero`),所以去重与候选菜单的 React key 都必须用它,而不是显示 token。 + */ +export function chatReferenceKey(reference: ChatReference) { if (reference.type === 'resource') { return `resource:${reference.resourceId}:${reference.source}`; } diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 6a2706c4c..d06527f19 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -10,11 +10,13 @@ import { attachmentReferenceProvider } from '../src/features/project-workspace/r import { createResourceReferenceProvider } from '../src/features/project-workspace/reference-source/resourceReferenceProvider'; import { runtimeRegionReferenceProvider } from '../src/features/project-workspace/reference-source/runtimeRegionReferenceProvider'; import { useSkillReferenceProvider } from '../src/features/project-workspace/reference-source/skillReferenceProvider'; -import type { - AttachmentReference, - ChatReference, - ResourceReference, - RuntimeRegionReference, +import { + type AttachmentReference, + type ChatReference, + chatReferenceKey, + chatReferenceMentionToken, + type ResourceReference, + type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; @@ -106,6 +108,25 @@ describe('资源引用 provider', () => { expect(provider.match?.('missing')).toEqual([]); }); + it('同名不同目录的素材是两条候选:显示名相同,但身份键不同', () => { + const sameName = createResourceReferenceProvider({ + assets: [ + asset('asset-hero-a', 'character', 'image/png', 'characters/hero.png'), + asset('asset-hero-b', 'character', 'image/png', 'enemies/hero.png'), + ], + }); + + const candidates = sameName.match?.('') ?? []; + // 显示 token 会撞(都是 `@hero`),所以候选菜单的 key 不能拿 token 当身份。 + expect(candidates.map((item) => chatReferenceMentionToken(item))).toEqual([ + '@hero', + '@hero', + ]); + expect(new Set(candidates.map((item) => chatReferenceKey(item))).size).toBe( + 2, + ); + }); + it('`toReference` 只认资源 part:资产已删除时不合成引用', () => { expect(provider.toReference({ type: 'input_text', text: '看素材' })).toBe( null,