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,